@lawaty/lacode 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,2250 @@
1
+ // src/lib/pool.ts
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ var CLAIM_TTL_MS = 10 * 60 * 1e3;
7
+ var DEAD_FILE_MS = 2 * CLAIM_TTL_MS;
8
+ var ORPHAN_TMP_MS = 60 * 1e3;
9
+ var CATALOG_TTL_MS = 10 * 60 * 1e3;
10
+ var STUCK_MIN_AGE_MS = 5 * 60 * 1e3;
11
+ var STUCK_IDLE_MS = 3 * 60 * 1e3;
12
+ var HANG_COOLDOWN_MS = 10 * 60 * 1e3;
13
+ var MAX_WEIGHT = 100;
14
+ var MAX_SLOTS = 4;
15
+ var COOLDOWN_BASE_MS = 60 * 1e3;
16
+ var COOLDOWN_MAX_MS = 15 * 60 * 1e3;
17
+ var STRIKE_DECAY_MS = 30 * 60 * 1e3;
18
+ var BASES = ["explore-fast", "implement-fast", "context-manager"];
19
+ var findPackageRoot = (start) => {
20
+ let dir = start;
21
+ for (let up = 0; up < 6; up++) {
22
+ try {
23
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === "@lawaty/lacode") return dir;
24
+ } catch {
25
+ }
26
+ const parent = dirname(dir);
27
+ if (parent === dir) break;
28
+ dir = parent;
29
+ }
30
+ return join(start, "..", "..");
31
+ };
32
+ var PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
33
+ var PRESETS_DIR = join(PACKAGE_ROOT, "presets");
34
+ var DEFAULT_PRESET = join(PRESETS_DIR, "free-tier.json");
35
+ var USER_CONFIG_DIR = join(homedir(), ".config", "lacode");
36
+ var USER_MODELS_PATH = join(USER_CONFIG_DIR, "pool.json");
37
+ var FALLBACK_SLOTS = [
38
+ { index: 1, model: "opencode/space-bunny-free", weight: 3 },
39
+ { index: 2, model: "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free", weight: 2 },
40
+ { index: 3, model: "opencode/big-pickle", weight: 1 },
41
+ { index: 4, model: "openrouter/thinkingmachines/inkling:free", weight: 1 }
42
+ ];
43
+ function parseSlots(parsed, origin) {
44
+ const problems = [];
45
+ const entries = parsed?.slots;
46
+ if (!Array.isArray(entries) || entries.length === 0) {
47
+ return { slots: FALLBACK_SLOTS, problems: [`${origin} has no non-empty "slots" array; using built-in defaults`] };
48
+ }
49
+ const slots = [];
50
+ entries.forEach((entry, i) => {
51
+ const model = typeof entry?.model === "string" ? entry.model.trim() : "";
52
+ const at = `slot ${i + 1}`;
53
+ if (slots.length >= MAX_SLOTS) {
54
+ problems.push(`${at} (${model || "?"}): the pool is capped at ${MAX_SLOTS} slots (one variant agent per base per slot is declared); entry ignored`);
55
+ return;
56
+ }
57
+ if (!model.includes("/")) return problems.push(`${at}: model must be written provider/model-id, got "${model}"`);
58
+ const weight = readWeight(entry, at, model, problems);
59
+ if (slots.some((s) => s.model === model)) return problems.push(`${at}: ${model} is already used by another slot; slots must be distinct models`);
60
+ slots.push({ index: slots.length + 1, model, weight });
61
+ });
62
+ if (slots.length === 0) {
63
+ problems.push(`${origin} yielded no usable slot; using built-in defaults`);
64
+ return { slots: FALLBACK_SLOTS, problems };
65
+ }
66
+ return { slots, problems };
67
+ }
68
+ var readJSON = (path4) => {
69
+ try {
70
+ return { ok: true, value: JSON.parse(readFileSync(path4, "utf8")) };
71
+ } catch (error) {
72
+ return { ok: false, error: String(error) };
73
+ }
74
+ };
75
+ function readOptional(path4, origin, problems) {
76
+ const result = readJSON(path4);
77
+ if (!result.ok) {
78
+ if (existsSync(path4)) problems.push(`${origin} unreadable (${result.error}); trying the next source`);
79
+ return void 0;
80
+ }
81
+ return result.value;
82
+ }
83
+ function resolveModels(input = {}) {
84
+ const problems = [];
85
+ if (input.models !== void 0) {
86
+ const origin = "plugin options";
87
+ if (typeof input.models !== "object" || input.models === null || Array.isArray(input.models)) {
88
+ return { slots: FALLBACK_SLOTS, problems: [`${origin}: "models" must be an object like {"slots":[...]}`], origin };
89
+ }
90
+ const parsed = parseSlots(input.models, origin);
91
+ return { slots: parsed.slots, problems: [...problems, ...parsed.problems], origin };
92
+ }
93
+ if (input.modelsFile) {
94
+ const result = readJSON(input.modelsFile);
95
+ if (!result.ok) return { slots: FALLBACK_SLOTS, problems: [`${input.modelsFile} unreadable (${result.error}); using built-in defaults`], origin: input.modelsFile };
96
+ const parsed = parseSlots(result.value, input.modelsFile);
97
+ return { slots: parsed.slots, problems: parsed.problems, origin: input.modelsFile };
98
+ }
99
+ const user = readOptional(USER_MODELS_PATH, USER_MODELS_PATH, problems);
100
+ if (user !== void 0) {
101
+ const parsed = parseSlots(user, USER_MODELS_PATH);
102
+ if (parsed.slots !== FALLBACK_SLOTS) return { slots: parsed.slots, problems, origin: USER_MODELS_PATH };
103
+ problems.push(...parsed.problems);
104
+ }
105
+ const preset = readOptional(DEFAULT_PRESET, DEFAULT_PRESET, problems);
106
+ if (preset !== void 0) {
107
+ const parsed = parseSlots(preset, "presets/free-tier.json");
108
+ return { slots: parsed.slots, problems: [...problems, ...parsed.problems], origin: "presets/free-tier.json" };
109
+ }
110
+ return { slots: FALLBACK_SLOTS, problems: [...problems, `no pool source found (${USER_MODELS_PATH}, ${DEFAULT_PRESET}); using built-in defaults`], origin: "built-in defaults" };
111
+ }
112
+ function ensureUserConfigDir() {
113
+ try {
114
+ mkdirSync(USER_CONFIG_DIR, { recursive: true });
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+ function readWeight(entry, at, model, problems) {
121
+ const raw = entry?.weight;
122
+ if (raw === void 0) {
123
+ const legacy = entry?.tier;
124
+ if (legacy !== void 0) {
125
+ problems.push(
126
+ `weight: "tier" is deprecated; write "weight" instead (primary is weight 2, overflow is weight 1), got ${JSON.stringify(legacy)}`
127
+ );
128
+ if (legacy === "primary" || legacy === "overflow") return legacy === "primary" ? 2 : 1;
129
+ }
130
+ return 1;
131
+ }
132
+ const weight = typeof raw === "number" ? raw : Number(raw);
133
+ if (!Number.isFinite(weight) || weight <= 0 || weight > MAX_WEIGHT) {
134
+ problems.push(`${at} (${model}): weight must be a number in (0, ${MAX_WEIGHT}], got ${JSON.stringify(raw)}; using weight 1`);
135
+ return 1;
136
+ }
137
+ return weight;
138
+ }
139
+ var LIMIT_PATTERNS = [
140
+ /rate[\s_-]?limit/i,
141
+ /usage limit/i,
142
+ /too many requests/i,
143
+ /\b429\b/,
144
+ /quota/i,
145
+ /freelimiterror/i,
146
+ /overloaded/i
147
+ ];
148
+ var isLimitText = (text) => LIMIT_PATTERNS.some((re) => re.test(text));
149
+ var statedCooldown = (text) => {
150
+ const hm = /resets?\s+in\s+(\d+)\s*(?:h|hr|hour)s?\s*(\d+)?\s*(?:m|min|minute)?s?/i.exec(text);
151
+ if (hm) return (Number(hm[1]) * 60 + (hm[2] ? Number(hm[2]) : 0)) * 60 * 1e3;
152
+ const m = /resets?\s+in\s+(\d+)\s*(?:m|min|minute)s?/i.exec(text);
153
+ if (m) return Number(m[1]) * 60 * 1e3;
154
+ const s = /retry[\s-]?in\s+(\d+)\s*(?:s|sec|second)s?/i.exec(text);
155
+ if (s) return Number(s[1]) * 1e3;
156
+ return void 0;
157
+ };
158
+ var retryAfterMs = (headers) => {
159
+ if (!headers) return void 0;
160
+ for (const [key, value] of Object.entries(headers)) {
161
+ if (key.toLowerCase() !== "retry-after") continue;
162
+ const seconds = Number(value);
163
+ if (Number.isFinite(seconds) && seconds > 0) return Math.min(seconds * 1e3, COOLDOWN_MAX_MS);
164
+ const when = Date.parse(value);
165
+ if (Number.isFinite(when)) return Math.min(Math.max(when - Date.now(), 1e3), COOLDOWN_MAX_MS);
166
+ }
167
+ return void 0;
168
+ };
169
+ function state(opts = {}) {
170
+ const id = opts.id ?? String(process.pid);
171
+ const dir = opts.dir ?? join(homedir(), ".local", "share", "opencode", "agent-pool");
172
+ const modelsFile = opts.modelsFile;
173
+ const models = opts.models;
174
+ const key = /* @__PURE__ */ Symbol.for(`agent-pool:state:${id}:${dir}:${modelsFile ?? ""}:${models === void 0 ? "" : "inline"}`);
175
+ const g = globalThis;
176
+ if (!g[key]) {
177
+ g[key] = {
178
+ own: /* @__PURE__ */ new Map(),
179
+ models: /* @__PURE__ */ new Map(),
180
+ known: /* @__PURE__ */ new Set(),
181
+ warned: /* @__PURE__ */ new Set(),
182
+ initialized: false,
183
+ dirReady: false,
184
+ catalog: /* @__PURE__ */ new Map(),
185
+ catalogAt: 0,
186
+ cooling: /* @__PURE__ */ new Map(),
187
+ activity: /* @__PURE__ */ new Map(),
188
+ reaped: 0,
189
+ reaping: false,
190
+ lastSweep: 0,
191
+ id,
192
+ dir,
193
+ modelsFile,
194
+ inlineModels: models,
195
+ origin: "unresolved",
196
+ slots: []
197
+ };
198
+ }
199
+ return g[key];
200
+ }
201
+ function ensureSlots(s, onWarn) {
202
+ if (s.slots.length > 0) return s.slots;
203
+ const { slots, problems, origin } = resolveModels({ models: s.inlineModels, modelsFile: s.modelsFile });
204
+ s.slots = slots;
205
+ s.origin = origin;
206
+ for (const problem of problems) onWarn(problem);
207
+ return slots;
208
+ }
209
+ var claimKey = (sessionID, callID) => `${sessionID}:${callID}`;
210
+ var selfFile = (id) => `claims.${id}.json`;
211
+ var limitFile = (model) => `limit.${model.replace(/[^a-zA-Z0-9._-]/g, "__")}.json`;
212
+ function init(s, onWarn) {
213
+ ensureSlots(s, onWarn);
214
+ if (s.initialized) return;
215
+ s.initialized = true;
216
+ try {
217
+ mkdirSync(s.dir, { recursive: true });
218
+ s.dirReady = true;
219
+ try {
220
+ unlinkSync(join(s.dir, selfFile(s.id)));
221
+ } catch {
222
+ }
223
+ } catch {
224
+ onWarn(`claim dir ${s.dir} unusable; pool load tracked in memory only`);
225
+ }
226
+ }
227
+ function pruneOwn(s) {
228
+ const now = Date.now();
229
+ for (const [key, claim] of s.own) if (now - claim.t > CLAIM_TTL_MS) s.own.delete(key);
230
+ }
231
+ function publish(s) {
232
+ if (!s.dirReady) return;
233
+ const now = Date.now();
234
+ const payload = {};
235
+ for (const [key, claim] of s.own) if (now - claim.t <= CLAIM_TTL_MS) payload[key] = claim;
236
+ try {
237
+ writeFileSync(join(s.dir, `${selfFile(s.id)}.tmp`), JSON.stringify(payload));
238
+ renameSync(join(s.dir, `${selfFile(s.id)}.tmp`), join(s.dir, selfFile(s.id)));
239
+ } catch {
240
+ s.dirReady = false;
241
+ }
242
+ }
243
+ function readSiblings(s) {
244
+ const siblings = [];
245
+ let entries;
246
+ try {
247
+ entries = readdirSync(s.dir);
248
+ } catch {
249
+ return siblings;
250
+ }
251
+ const now = Date.now();
252
+ for (const entry of entries) {
253
+ const path4 = join(s.dir, entry);
254
+ let age;
255
+ try {
256
+ age = now - statSync(path4).mtimeMs;
257
+ } catch {
258
+ continue;
259
+ }
260
+ if (entry.endsWith(".tmp")) {
261
+ if (age > ORPHAN_TMP_MS) try {
262
+ unlinkSync(path4);
263
+ } catch {
264
+ }
265
+ continue;
266
+ }
267
+ const match = /^claims\.(.+)\.json$/.exec(entry);
268
+ if (!match || match[1] === s.id) continue;
269
+ if (age > DEAD_FILE_MS) {
270
+ try {
271
+ unlinkSync(path4);
272
+ } catch {
273
+ }
274
+ continue;
275
+ }
276
+ try {
277
+ const parsed = JSON.parse(readFileSync(path4, "utf8"));
278
+ const claims = Object.values(parsed).filter(
279
+ (c) => c && typeof c.v === "string" && typeof c.m === "string" && now - c.t <= CLAIM_TTL_MS
280
+ );
281
+ siblings.push({ id: match[1], claims });
282
+ } catch {
283
+ }
284
+ }
285
+ return siblings;
286
+ }
287
+ function readLimits(s) {
288
+ const cooling = /* @__PURE__ */ new Map();
289
+ let entries;
290
+ try {
291
+ entries = readdirSync(s.dir);
292
+ } catch {
293
+ return cooling;
294
+ }
295
+ const now = Date.now();
296
+ for (const entry of entries) {
297
+ if (!entry.startsWith("limit.") || !entry.endsWith(".json")) continue;
298
+ const path4 = join(s.dir, entry);
299
+ try {
300
+ const strike = JSON.parse(readFileSync(path4, "utf8"));
301
+ if (typeof strike.until !== "number" || typeof strike.model !== "string") continue;
302
+ if (strike.until > now) cooling.set(strike.model, strike);
303
+ else unlinkSync(path4);
304
+ } catch {
305
+ try {
306
+ unlinkSync(path4);
307
+ } catch {
308
+ }
309
+ }
310
+ }
311
+ s.cooling = cooling;
312
+ return cooling;
313
+ }
314
+ function recordStrike(s, model, reason, stated) {
315
+ if (!s.dirReady) return;
316
+ const now = Date.now();
317
+ const prior = s.cooling.get(model);
318
+ const strikes = prior && now - prior.last < STRIKE_DECAY_MS ? prior.strikes + 1 : 1;
319
+ const backoff = Math.min(COOLDOWN_MAX_MS, COOLDOWN_BASE_MS * 2 ** (strikes - 1));
320
+ const strike = { model, until: now + (stated ?? backoff), strikes, reason, last: now };
321
+ s.cooling.set(model, strike);
322
+ const path4 = join(s.dir, limitFile(model));
323
+ try {
324
+ writeFileSync(`${path4}.tmp`, JSON.stringify(strike));
325
+ renameSync(`${path4}.tmp`, path4);
326
+ } catch {
327
+ }
328
+ return strike;
329
+ }
330
+ function refreshCatalog(s, serverUrl) {
331
+ if (!serverUrl || Date.now() - s.catalogAt < CATALOG_TTL_MS) return;
332
+ s.catalogAt = Date.now();
333
+ void (async () => {
334
+ try {
335
+ const response = await fetch(new URL("/api/model", serverUrl).toString());
336
+ if (!response.ok) return;
337
+ const payload = await response.json();
338
+ const catalog = /* @__PURE__ */ new Map();
339
+ for (const m of payload.data ?? []) catalog.set(`${m.providerID}/${m.id}`, m.status ?? "unknown");
340
+ if (catalog.size > 0) s.catalog = catalog;
341
+ } catch {
342
+ }
343
+ })();
344
+ }
345
+ function isAvailable(s, model, cooling) {
346
+ if (cooling.has(model)) return false;
347
+ const status = s.catalog.get(model);
348
+ return status === void 0 || status === "active" || status === "unknown";
349
+ }
350
+ function snapshot(s) {
351
+ pruneOwn(s);
352
+ const siblings = readSiblings(s);
353
+ const cooling = readLimits(s);
354
+ const counts = /* @__PURE__ */ new Map();
355
+ const bump = (m) => counts.set(m, (counts.get(m) ?? 0) + 1);
356
+ for (const claim of s.own.values()) bump(claim.m);
357
+ for (const sibling of siblings) for (const claim of sibling.claims) bump(claim.m);
358
+ return { counts, cooling, siblings, own: s.own, dirReady: s.dirReady };
359
+ }
360
+ var loadOf = (counts, model) => counts.get(model) ?? 0;
361
+ var underCeiling = (slot, counts) => loadOf(counts, slot.model) < slot.weight;
362
+ function pickSlot(s, snap) {
363
+ const slots = s.slots.length > 0 ? s.slots : FALLBACK_SLOTS;
364
+ const usable = slots.filter((slot) => isAvailable(s, slot.model, snap.cooling));
365
+ const pool = usable.length > 0 ? usable : slots;
366
+ const open = pool.filter((slot) => underCeiling(slot, snap.counts));
367
+ if (open.length > 0) return open.reduce((best, slot) => slot.weight > best.weight ? slot : best, open[0]);
368
+ return pool.reduce((best, slot) => loadOf(snap.counts, slot.model) < loadOf(snap.counts, best.model) ? slot : best, pool[0]);
369
+ }
370
+ function agentFor(base, slot) {
371
+ return `${base}-${slot}`;
372
+ }
373
+ function acquire(s, base, slot, ownerSession, key, target) {
374
+ s.own.set(key, { v: base, k: slot.index, m: slot.model, t: Date.now(), s: ownerSession, g: target });
375
+ if (target) s.activity.set(target, Date.now());
376
+ publish(s);
377
+ }
378
+ function release(s, key) {
379
+ if (s.own.delete(key)) publish(s);
380
+ }
381
+ function bindChild(s, parentID, childID) {
382
+ const candidates = [...s.own.entries()].filter(([, c]) => c.s === parentID && !c.g).sort((a, b) => a[1].t - b[1].t);
383
+ if (candidates.length === 0) return false;
384
+ candidates[0][1].g = childID;
385
+ s.activity.set(childID, Date.now());
386
+ publish(s);
387
+ return true;
388
+ }
389
+ function noteActivity(s, sessionID) {
390
+ s.activity.set(sessionID, Date.now());
391
+ }
392
+ function idleFor(s, claim, now) {
393
+ const target = claim.g;
394
+ if (!target) return Infinity;
395
+ const seen = s.activity.get(target);
396
+ return seen ? now - seen : Infinity;
397
+ }
398
+ function findStuck(s, now = Date.now()) {
399
+ const stuck = [];
400
+ for (const [key, claim] of s.own) {
401
+ if (now - claim.t <= STUCK_MIN_AGE_MS) continue;
402
+ if (idleFor(s, claim, now) < STUCK_IDLE_MS) continue;
403
+ stuck.push({ key, claim });
404
+ }
405
+ return stuck;
406
+ }
407
+ function releaseStuck(s, keys) {
408
+ for (const key of keys) s.own.delete(key);
409
+ if (keys.length > 0) publish(s);
410
+ }
411
+ function splitModel(model) {
412
+ const slash = model.indexOf("/");
413
+ if (slash < 0) return { provider: model, id: "" };
414
+ return { provider: model.slice(0, slash), id: model.slice(slash + 1) };
415
+ }
416
+ var providerOf = (model) => splitModel(model).provider;
417
+ function coolProvider(s, provider, until, reason) {
418
+ const affected = [];
419
+ for (const slot of s.slots.length > 0 ? s.slots : FALLBACK_SLOTS) {
420
+ if (providerOf(slot.model) !== provider) continue;
421
+ const existing = s.cooling.get(slot.model);
422
+ const untilMs = Math.max(until, existing?.until ?? 0);
423
+ const strike = recordStrike(s, slot.model, reason, untilMs - Date.now());
424
+ if (strike) affected.push(strike);
425
+ }
426
+ return affected;
427
+ }
428
+
429
+ // src/agents.ts
430
+ var DEEP_TIERS = ["explore-deep", "implement-deep"];
431
+ var POOLED_BASES = ["explore-fast", "implement-fast", "context-manager"];
432
+ var AGENTS = {
433
+ "build": {
434
+ "description": "Default root agent. Orchestrates rather than investigates: read/glob/grep/list/webfetch/websearch are denied by permission, so every question goes to @explore-fast and every code change to @implement-fast. Keeps edit/write/bash for applying and verifying what comes back.",
435
+ "mode": "primary",
436
+ "temperature": 0.1,
437
+ prompt: `# Role
438
+
439
+ You are the default root agent. You orchestrate work \u2014 you do not perform
440
+ investigation or implementation yourself.
441
+
442
+ You CANNOT read files, grep, glob, or list directories, and you cannot fetch
443
+ or search the web. Those tools are denied by configuration, not by convention.
444
+ Every question of the form "what does this file do", "where is X handled", or
445
+ "what does this error come from" belongs to an explorer, not to you.
446
+
447
+ # Delegation
448
+
449
+ - **Investigation \u2192 @explore-fast.** Give it specific, focused instructions
450
+ ("read src/auth.ts", "grep for 'migration' in *.py", "list the src/
451
+ directory"), one batch per question. Ask for the exact lines or
452
+ file:line references you need, not a survey.
453
+ - **Implementation \u2192 @implement-fast**, with a finalized plan: the exact
454
+ steps, the files to touch, and the constraints you already learned from the
455
+ explorers. It edits, writes, and runs tests.
456
+ - Never call @explore-deep or @implement-deep yourself. Each tier escalates on
457
+ its own when it is stuck; that ladder belongs to the subagents, not to you.
458
+
459
+ The agents you spawn are the pooled \`explore-fast\` / \`implement-fast\` bases \u2014
460
+ spawn the bare name and let the pool route, never a \`-1\`/\`-2\` slot variant.
461
+
462
+ # What stays yours
463
+
464
+ \`edit\`, \`write\`, and \`bash\` are available to you. Use them to apply what a
465
+ subagent reported back, and to verify the result: run tests, typecheck, lint,
466
+ build, and git.
467
+
468
+ Do NOT use bash to inspect the codebase. \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`,
469
+ \`grep\`, \`find\`, \`ls\`, \`tree\` and friends are exploration, and exploration goes
470
+ to @explore-fast. Bash is for executing and verifying, not for looking.
471
+
472
+ # Repository context
473
+
474
+ Before delegating a non-trivial task, tell @explore-fast to check
475
+ \`.opencode/context/\` first (\`ls .opencode/context\`, never the \`glob\` tool \u2014 it
476
+ skips hidden directories) and to read only the file relevant to the task:
477
+ architecture.md for architectural questions, contexts.md for subsystem
478
+ questions, conventions.md before proposing code changes, workflows.md when
479
+ tracing runtime behavior, decisions.md for architectural constraints. Ask it to
480
+ start from the canonical entry points the map lists rather than exploring
481
+ broadly.
482
+
483
+ Files the user explicitly named always take priority over the context map.
484
+
485
+ Never edit \`.opencode/context/\` \u2014 context maintenance belongs to the
486
+ context-manager agent (\`/context-update\` or its automatic post-work runs). If
487
+ your work reveals something that belongs in the map, say so in your final
488
+ report instead of editing it yourself.
489
+
490
+ # Reporting
491
+
492
+ When you report back, say which explorer answered which question and name the
493
+ context file(s) that actually shaped the work (or state plainly that the map was
494
+ missing). Never paste context file contents into the report.`,
495
+ permission: {
496
+ "read": "deny",
497
+ "glob": "deny",
498
+ "grep": "deny",
499
+ "list": "deny",
500
+ // No "write" key appears in any permission block below: opencode folds the
501
+ // write and apply_patch tools onto "edit", so a "write" rule is never read.
502
+ "edit": {
503
+ "*": "allow",
504
+ "*/.opencode/context/**": "deny",
505
+ ".opencode/context/**": "deny"
506
+ },
507
+ "bash": "allow",
508
+ "webfetch": "deny",
509
+ "websearch": "deny",
510
+ "question": "allow",
511
+ "task": {
512
+ "*": "deny",
513
+ "explore-fast*": "allow",
514
+ "implement-fast*": "allow"
515
+ }
516
+ }
517
+ },
518
+ "plan": {
519
+ "description": "Read-only planner. Identical to @basic: it cannot read, grep, glob, list or run commands, so all investigation is delegated to @explore-fast. Produce a plan; do not edit.",
520
+ "mode": "primary",
521
+ "temperature": 0.1,
522
+ prompt: `You are a senior software architect. Your ONLY job is analysis and planning.
523
+
524
+ You CANNOT read files, grep, glob, list directories, or run commands directly. All codebase investigation must be delegated.
525
+
526
+ When you need to understand the codebase:
527
+ 1. Determine exactly what files or patterns need investigating
528
+ 2. Invoke @explore-fast with specific, focused instructions (e.g. "read src/auth.ts", "grep for 'migration' in *.py files", "list the src/ directory")
529
+ 3. @explore-fast will return its findings \u2014 you process them and plan accordingly
530
+
531
+ Repository context: before delegating investigation for a non-trivial task,
532
+ tell the exploration agent to first check \`.opencode/context/\` in this project
533
+ (using \`ls\`, never \`glob\` \u2014 it skips hidden directories)
534
+ and to read only the file relevant to the task \u2014 architecture.md for
535
+ architectural questions, contexts.md for subsystem questions, conventions.md
536
+ before proposing code changes, workflows.md when tracing behavior,
537
+ decisions.md for architectural constraints. Ask it to start from the canonical
538
+ entry points the map lists rather than exploring broadly.
539
+
540
+ Files the user explicitly mentioned always take priority over the context map.
541
+
542
+ Never edit \`.opencode/context/\` \u2014 context maintenance belongs to the
543
+ context-manager agent (\`/context-update\` or its automatic post-work runs). If
544
+ your plan implies the map needs a change, say so in the plan instead.
545
+
546
+ When given a task:
547
+ 1. Think through what codebase context you need
548
+ 2. Delegate investigation to @explore-fast in focused batches
549
+ 3. Analyze findings for dependencies, edge cases, and architecture impact
550
+ 4. Produce a structured output:
551
+ - Summary of the change
552
+ - [ ] Checklist of concrete implementation steps
553
+ - Files to create or modify
554
+ - Risks or gotchas
555
+
556
+ Cite the relevant context file(s) and canonical entry points in the plan when
557
+ they actually shaped it (e.g. "Repository context indicates order mutations
558
+ belong in Shop/Application, not the Bot layer"). Do not paste the context files
559
+ into the plan. If the work would introduce information worth remembering for
560
+ future agents \u2014 a new boundary, canonical entry point, workflow, convention, or
561
+ architectural decision \u2014 note it at the end of the plan so /context-update can
562
+ capture it.
563
+
564
+ Do NOT edit files. Do NOT run commands. Do NOT use Read, Grep, Glob, or List tools directly.
565
+ Once the plan is complete, present it clearly so the caller can implement it step by step.`,
566
+ permission: {
567
+ "read": "deny",
568
+ "glob": "deny",
569
+ "grep": "deny",
570
+ "list": "deny",
571
+ "edit": {
572
+ "*": "deny",
573
+ ".opencode/plans/*.md": "allow",
574
+ "*/.opencode/plans/*.md": "allow",
575
+ "../../.local/share/opencode/plans/*.md": "allow"
576
+ },
577
+ "bash": "deny",
578
+ "webfetch": "deny",
579
+ "websearch": "deny",
580
+ "question": "allow",
581
+ "task": {
582
+ "*": "deny",
583
+ "explore-fast*": "allow",
584
+ "implement-fast*": "allow"
585
+ }
586
+ }
587
+ },
588
+ "explore-fast": {
589
+ "description": "Default read-only codebase exploration. Free via Zen. Escalates to @explore-deep when stuck or looping.",
590
+ "mode": "subagent",
591
+ "temperature": 0.2,
592
+ prompt: `# Role
593
+
594
+ You are the default codebase exploration agent. You are read-only: you locate,
595
+ read, and summarize code, configuration, and documentation to answer questions
596
+ or gather context for a plan. You never edit or write files.
597
+
598
+ Be thorough but efficient. Prefer targeted grep/glob searches over reading
599
+ whole directories. When you report findings, cite exact file paths and line
600
+ ranges so the caller can verify or act on them directly.
601
+
602
+ # Repository context map
603
+
604
+ Before broad exploration, check whether \`.opencode/context/\` exists in this
605
+ project (\`ls .opencode/context\` \u2014 never the \`glob\` tool, which skips hidden
606
+ directories). If it exists, read only the file relevant to the question \u2014
607
+ architecture.md (architectural questions), contexts.md (subsystem questions),
608
+ conventions.md (repository conventions), workflows.md (tracing behavior),
609
+ decisions.md (architectural constraints) \u2014 then start from the canonical entry
610
+ points it lists. Explore beyond them only when the question requires it.
611
+
612
+ Use the map to narrow exploration, not to expand it. Follow dependencies only
613
+ when necessary to answer the question. Do not inspect unrelated modules merely
614
+ to gain a complete understanding of the repository \u2014 minimum sufficient
615
+ exploration, not zero exploration.
616
+
617
+ If the user named specific files or paths, those take priority: use the map to
618
+ understand them and find the surrounding code, never as a reason to skip them.
619
+
620
+ If the map conflicts with the source, trust the source, say so in your report,
621
+ and never repeat the stale claim. Never edit \`.opencode/context/\` files \u2014 the
622
+ context-manager agent owns those (\`/context-update\` or its automatic post-work
623
+ runs). Report what should be updated there instead.
624
+
625
+ <!-- MERGE POINT: paste any project-specific exploration conventions from
626
+ your current plan.txt / explore instructions here (e.g. which
627
+ directories to ignore, naming conventions, where docs live). -->
628
+
629
+ # Escalation
630
+
631
+ If you hit any of the following, STOP and delegate to @explore-deep via the
632
+ task tool instead of continuing to search:
633
+
634
+ - You've made more than ~8 tool calls (read/glob/grep/bash) without
635
+ converging on an answer
636
+ - The same search strategy (same query pattern, same directory) has failed
637
+ twice in a row
638
+ - The code you need to understand involves genuinely complex logic you're
639
+ not confident you're reading correctly (e.g. dense concurrency, generated
640
+ code, deep inheritance/dependency chains, obscure build tooling)
641
+ - You cannot locate a symbol, config value, or file the task clearly
642
+ requires, after searching from at least two different angles (e.g. by
643
+ name and by usage)
644
+
645
+ Do not escalate on the first dead end \u2014 try at least one alternative search
646
+ strategy yourself first (different keywords, different tool, broader or
647
+ narrower scope).
648
+
649
+ When escalating, pass full context to @explore-deep:
650
+ - The exact question you were asked to answer
651
+ - Every search strategy you already tried and what each one returned
652
+ - Your best current hypothesis, even if unconfirmed
653
+
654
+ Never guess or fabricate a file path, line number, or code behavior to avoid
655
+ escalating. A wrong answer is worse than an escalation.
656
+
657
+ # No further escalation beyond deep
658
+
659
+ You may only escalate to @explore-deep. Do not attempt to call @implement-fast,
660
+ @implement-deep, or any other agent \u2014 exploration agents only explore.`,
661
+ permission: {
662
+ "read": "allow",
663
+ "glob": "allow",
664
+ "grep": "allow",
665
+ "list": "allow",
666
+ "edit": "deny",
667
+ "bash": "allow",
668
+ "webfetch": "allow",
669
+ "websearch": "allow",
670
+ "question": "allow",
671
+ "task": {
672
+ "*": "deny",
673
+ "explore-deep": "allow"
674
+ }
675
+ }
676
+ },
677
+ "explore-deep": {
678
+ "description": "Last-resort read-only exploration for genuinely hard problems @explore-fast fails on. Most expensive explore tier \u2014 use deliberately.",
679
+ "mode": "subagent",
680
+ "temperature": 0.2,
681
+ prompt: `# Role
682
+
683
+ You are the last-resort codebase exploration agent, called only when
684
+ @explore-fast could not resolve a question. You are read-only: you locate,
685
+ read, and summarize code, configuration, and documentation. You never edit or
686
+ write files.
687
+
688
+ You will typically receive context from @explore-fast including what was
689
+ already tried and what failed \u2014 read it carefully before starting. Do not
690
+ repeat a search strategy that's already been reported as unproductive; start
691
+ from a genuinely different angle (e.g. searching by behavior/output instead of
692
+ by name, checking git history or tests for hints, reading adjacent modules for
693
+ context).
694
+
695
+ # Repository context map
696
+
697
+ You will usually receive findings that already include what
698
+ \`.opencode/context/\` said about the area. Treat that as a starting point, not
699
+ as a finished answer: the code is authoritative, and the map may be stale or
700
+ incomplete.
701
+
702
+ If you do need to consult the map, list it with \`ls .opencode/context\` \u2014 never
703
+ the \`glob\` tool, which skips hidden directories \u2014 then read only the file
704
+ relevant to the question \u2014 architecture.md, contexts.md, conventions.md,
705
+ workflows.md, decisions.md \u2014 and start from its canonical entry points. Explore
706
+ beyond them only when the question genuinely requires it. Do not re-walk the
707
+ repository to build a complete picture, and do not repeat searches already
708
+ reported as unproductive. Never edit \`.opencode/context/\` files \u2014 the
709
+ context-manager agent owns those (\`/context-update\` or its automatic post-work
710
+ runs). Report what should be updated there instead.
711
+
712
+ <!-- MERGE POINT: paste any project-specific exploration conventions from
713
+ your current plan.txt / explore instructions here. -->
714
+
715
+ # No further escalation
716
+
717
+ You are the last resort \u2014 there is no one above you to hand this off to. If,
718
+ after genuinely exhausting reasonable approaches, you still cannot answer the
719
+ question:
720
+
721
+ - STOP. Do not keep looping or guessing.
722
+ - Report back clearly and specifically: what was asked, what you tried
723
+ (including what @explore-fast already tried, if provided), and exactly what
724
+ information is missing or ambiguous.
725
+ - If relevant, state what additional access, file, or human input would
726
+ resolve it (e.g. "this appears to be defined in a private package not in
727
+ this repo").
728
+
729
+ Never fabricate a file path, line number, or code behavior to produce an
730
+ answer. An honest "I couldn't find this, here's why" is the correct output
731
+ when that's the truth.
732
+
733
+ You may only explore. Do not attempt to call @implement-fast, @implement-deep,
734
+ or any other agent.`,
735
+ permission: {
736
+ "read": "allow",
737
+ "glob": "allow",
738
+ "grep": "allow",
739
+ "list": "allow",
740
+ "edit": "deny",
741
+ "bash": "allow",
742
+ "webfetch": "allow",
743
+ "websearch": "allow",
744
+ "question": "allow",
745
+ "task": {
746
+ "*": "deny"
747
+ }
748
+ }
749
+ },
750
+ "implement-fast": {
751
+ "description": "Default agent that executes a finalized plan. Escalates to @implement-deep when stuck or looping.",
752
+ "mode": "subagent",
753
+ "temperature": 0.2,
754
+ prompt: `# Role
755
+
756
+ You are the default agent for executing a finalized plan. You read, edit,
757
+ write, and run code/bash to make the plan's changes real. Work from the plan
758
+ as given \u2014 if it's ambiguous or you believe it's wrong, say so explicitly
759
+ rather than silently improvising a different approach.
760
+
761
+ Make changes incrementally where possible, and verify your own work (run
762
+ tests/build/lint if available) before considering a step done.
763
+
764
+ # Repository context
765
+
766
+ If \`.opencode/context/\` exists (\`ls .opencode/context\` \u2014 never the \`glob\`
767
+ tool, which skips hidden directories), use it as a navigation aid:
768
+ conventions.md
769
+ before writing or modifying code, architecture.md and contexts.md for layer
770
+ and boundary decisions, workflows.md when the change touches a flow,
771
+ decisions.md for architectural constraints that must be respected. Read only
772
+ the relevant file, and start from the canonical entry points it lists. Files
773
+ named by the user or the plan always take priority over the map, and the map
774
+ never justifies skipping work the plan asked for.
775
+
776
+ Do NOT edit \`.opencode/context/\` \u2014 context maintenance is a separate
777
+ responsibility handled by the context-manager agent (\`/context-update\` or its
778
+ automatic post-work runs). If your work reveals something that belongs in the
779
+ map (a new boundary, entry point, workflow, convention, or decision), say so in
780
+ your final report instead of editing it yourself.
781
+
782
+ <!-- MERGE POINT: paste your current implement.txt content here \u2014 task
783
+ execution conventions, commit/testing requirements, code style rules,
784
+ etc. This section should carry over everything from the original
785
+ shared implement.txt that both tiers need. -->
786
+
787
+ # Escalation
788
+
789
+ If you hit any of the following, STOP and delegate to @implement-deep via
790
+ the task tool instead of continuing to retry:
791
+
792
+ - The same error or test failure recurs after 2 different attempted fixes
793
+ - You've made more than ~8 tool calls without a concrete, verifiable step of
794
+ progress (a passing test, a file that now does what it's supposed to, etc.)
795
+ - The task requires reasoning you're not confident in \u2014 complex concurrency,
796
+ subtle type/generic errors, ambiguous or conflicting requirements in the
797
+ plan, or a fix that would require touching many unrelated files
798
+ - You find yourself about to revert and redo the same change a third time
799
+
800
+ Do not escalate at the first error \u2014 try at least one different approach
801
+ yourself first (different fix, different file, re-reading the relevant code).
802
+
803
+ When escalating, pass full context to @implement-deep:
804
+ - The exact plan step / task you were given
805
+ - Every approach you already tried and why each one failed (errors,
806
+ diffs, test output)
807
+ - The current state of the code (what's already changed vs. untouched)
808
+
809
+ Do not silently abandon a broken change and start over without escalating \u2014
810
+ leaving inconsistent partial edits behind is worse than asking for help.
811
+
812
+ # No further escalation beyond deep
813
+
814
+ You may only escalate to @implement-deep. Do not attempt to call
815
+ @explore-fast, @explore-deep, or any other agent for implementation help \u2014
816
+ if you need investigation rather than execution, note that explicitly when
817
+ you escalate so @implement-deep can decide whether to explore first.`,
818
+ permission: {
819
+ "read": "allow",
820
+ "glob": "allow",
821
+ "grep": "allow",
822
+ "list": "allow",
823
+ "edit": {
824
+ "*": "allow",
825
+ "*/.opencode/context/**": "deny",
826
+ ".opencode/context/**": "deny"
827
+ },
828
+ "bash": "allow",
829
+ "webfetch": "allow",
830
+ "websearch": "allow",
831
+ "question": "allow",
832
+ "task": {
833
+ "*": "deny",
834
+ "implement-deep": "allow"
835
+ }
836
+ }
837
+ },
838
+ "implement-deep": {
839
+ "description": "Last-resort executor for genuinely hard problems @implement-fast fails on. Most expensive implement tier \u2014 use deliberately.",
840
+ "mode": "subagent",
841
+ "temperature": 0.2,
842
+ prompt: `# Role
843
+
844
+ You are the last-resort agent for executing a finalized plan, called only
845
+ when @implement-fast could not complete a step. You read, edit, write, and
846
+ run code/bash to make the plan's changes real.
847
+
848
+ You will typically receive context from @implement-fast including what was
849
+ already tried, what failed, and the current state of the code \u2014 read it
850
+ carefully before touching anything. Don't repeat an approach already reported
851
+ as failed. Before making further changes, confirm you understand the current
852
+ state of the code (it may be a partial, broken edit left by @implement-fast)
853
+ rather than assuming it matches the original plan.
854
+
855
+ # Repository context
856
+
857
+ If \`.opencode/context/\` exists (\`ls .opencode/context\` \u2014 never the \`glob\`
858
+ tool, which skips hidden directories), use it as a navigation aid:
859
+ conventions.md
860
+ before writing or modifying code, architecture.md and contexts.md for layer
861
+ and boundary decisions, workflows.md when the change touches a flow,
862
+ decisions.md for architectural constraints that must be respected. Read only
863
+ the relevant file. Files named by @implement-fast, the user, or the plan
864
+ always take priority, and the map never justifies skipping a step.
865
+
866
+ Do NOT edit \`.opencode/context/\` \u2014 that belongs to the context-manager agent
867
+ (\`/context-update\` or its automatic post-work runs). If your work reveals
868
+ something that belongs in the map, say so in your report instead.
869
+
870
+ <!-- MERGE POINT: paste your current implement.txt content here \u2014 the same
871
+ execution conventions used by implement-fast should apply here too. -->
872
+
873
+ # No further escalation
874
+
875
+ You are the last resort \u2014 there is no one above you to hand this off to. If,
876
+ after genuinely exhausting reasonable approaches, you still cannot complete
877
+ the step:
878
+
879
+ - STOP. Do not keep looping, guessing, or making speculative changes.
880
+ - Leave the code in the most coherent state you can (prefer reverting a
881
+ broken partial change over leaving it half-done, unless partial progress is
882
+ clearly salvageable and you say so).
883
+ - Report back clearly and specifically: the exact error or blocker, what was
884
+ already tried (including @implement-fast's attempts, if provided), and what
885
+ you believe is needed to unblock it (missing information, a plan
886
+ revision, human judgment call, etc.).
887
+
888
+ Never mark a step as done if it isn't, and never fabricate a passing
889
+ test/build result. An honest "this is blocked, here's why" is the correct
890
+ output when that's the truth.
891
+
892
+ You may only implement. Do not attempt to call @explore-fast, @explore-deep,
893
+ or any other agent \u2014 if the blocker is a missing understanding of the
894
+ codebase rather than an execution problem, report that explicitly instead of
895
+ trying to explore yourself.`,
896
+ permission: {
897
+ "read": "allow",
898
+ "glob": "allow",
899
+ "grep": "allow",
900
+ "list": "allow",
901
+ "edit": {
902
+ "*": "allow",
903
+ "*/.opencode/context/**": "deny",
904
+ ".opencode/context/**": "deny"
905
+ },
906
+ "bash": "allow",
907
+ "webfetch": "allow",
908
+ "websearch": "allow",
909
+ "question": "allow",
910
+ "task": {
911
+ "*": "deny"
912
+ }
913
+ }
914
+ },
915
+ "context-manager": {
916
+ "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.",
917
+ "mode": "subagent",
918
+ "temperature": 0.1,
919
+ prompt: `# Role
920
+
921
+ You are the repository cartographer and memory maintainer. You are not a coding
922
+ agent. You maintain exactly one thing: the repository context map in
923
+ \`.opencode/context/\`.
924
+
925
+ The context map is a lightweight navigation index that future agents read
926
+ before exploring. It exists to answer "what should I look at first?" \u2014 never
927
+ "tell me everything about the repository". Source code is authoritative; the
928
+ map is a derived index of it.
929
+
930
+ # Owned files
931
+
932
+ You own exactly these five files and nothing else:
933
+
934
+ - \`.opencode/context/architecture.md\` \u2014 high-level architecture, major layers,
935
+ boundaries, important entry points, dependency rules. Target 50\u2013150 lines.
936
+ - \`.opencode/context/contexts.md\` \u2014 the major subsystems/modules. For each:
937
+ purpose, canonical start-here files, boundaries, dependencies, and what must
938
+ not be changed while working there. Target 50\u2013200 lines.
939
+ - \`.opencode/context/conventions.md\` \u2014 repository-specific conventions:
940
+ naming, patterns, error handling, testing, persistence, framework rules.
941
+ Target 30\u2013150 lines.
942
+ - \`.opencode/context/workflows.md\` \u2014 the few runtime flows worth tracing.
943
+ Target 30\u2013150 lines.
944
+ - \`.opencode/context/decisions.md\` \u2014 architectural decisions discovered
945
+ during work that are not already recorded in the project's own decision
946
+ log. Concise; variable length.
947
+
948
+ Never create, modify, or delete anything outside \`.opencode/context/\`. The
949
+ \`edit\` permission enforces this. Do not attempt to work around it with bash.
950
+
951
+ To look for the map, use \`list\` or \`ls .opencode/context\`. Never use the
952
+ \`glob\` tool for it: \`glob\` does not match hidden directories, so it reports
953
+ "no files" even when the map exists.
954
+
955
+ # Bootstrap
956
+
957
+ Trigger: \`.opencode/context/\` does not exist or is empty.
958
+
959
+ Explore progressively and stop as soon as you have a usable map:
960
+
961
+ 1. README files and any existing architecture or decision documentation.
962
+ 2. Project manifests (\`package.json\`, \`pyproject.toml\`, \`go.mod\`, \`Cargo.toml\`,
963
+ \u2026) for entry points, workspaces, and dependency structure.
964
+ 3. The top-level directory structure \u2014 one or two levels. Name what each
965
+ directory is for.
966
+ 4. Framework and build configuration (tsconfig, bundler config, CI, container
967
+ files) to learn how things are assembled and run.
968
+ 5. Application entry points. Read them.
969
+ 6. Obvious domain / application / infrastructure boundaries and the
970
+ interfaces between them.
971
+ 7. Only then inspect source selectively \u2014 the files that define the boundaries
972
+ you found.
973
+
974
+ You are mapping a city, not auditing every house. Do not attempt to
975
+ understand every file.
976
+
977
+ Then write the five files. For each significant subsystem, give a short
978
+ "Start here" list \u2014 at most about five canonical files, each with a one-phrase
979
+ reason. This is the single most valuable thing you produce: where an agent
980
+ should open its editor next.
981
+
982
+ # Incremental update
983
+
984
+ Trigger: \`.opencode/context/\` exists and work has just been done here.
985
+
986
+ 1. Start from the files that changed, plus their immediate architectural
987
+ neighborhood: what they import, who calls them, the boundary they sit on.
988
+ 2. Read only the context file(s) those changes could affect.
989
+ 3. Default to no edit. A changed file is not a changed fact: comments,
990
+ formatting, renames of local variables, added tests, dependency bumps, and
991
+ edits that stay inside one function rarely alter what the map should say.
992
+ Edit only when the work introduced or revealed information that will
993
+ materially help a future agent, such as:
994
+ - a new architectural boundary or module
995
+ - a new or moved canonical entry point
996
+ - a changed workflow
997
+ - a new repository convention
998
+ - an architectural decision
999
+ - a new dependency between subsystems
1000
+ - a renamed or relocated subsystem
1001
+ - a previously undocumented invariant
1002
+
1003
+ Do not update documentation merely because source files changed. Do not
1004
+ rewrite files wholesale. Do not make timestamp-only edits. Do not add a fact
1005
+ that is already stated in another context file \u2014 one fact has one home, and
1006
+ the other files link to it. If nothing material changed, make no edits and
1007
+ report "no update needed".
1008
+
1009
+ # Minimal exploration
1010
+
1011
+ Default shape:
1012
+
1013
+ repository structure
1014
+ \u2192 existing documentation
1015
+ \u2192 relevant entry points
1016
+ \u2192 relevant neighboring modules
1017
+ \u2192 deeper exploration only when necessary
1018
+
1019
+ Avoid:
1020
+
1021
+ repository
1022
+ \u2192 every directory
1023
+ \u2192 every file
1024
+ \u2192 every dependency
1025
+ \u2192 every caller
1026
+ \u2192 the whole codebase
1027
+
1028
+ Aim for minimum sufficient exploration. A few precise reads beat a broad
1029
+ sweep. When updating after a task, the changed files and their immediate
1030
+ neighborhood are almost always enough.
1031
+
1032
+ # Stay inside this project
1033
+
1034
+ The target repository is the only thing you are mapping. Everything outside its
1035
+ root is out of scope and is blocked:
1036
+
1037
+ - Do not read OpenCode's own configuration, prompts, plugins, session storage,
1038
+ or logs. They are not part of the project and hold no project facts.
1039
+ - Do not inspect other repositories, home directories, or system paths.
1040
+ - Do not treat a blocked tool call as a puzzle to solve. If a read or command
1041
+ is refused, use the project's own files instead and move on.
1042
+ - Do not search the filesystem root or use \`grep -r /\`.
1043
+
1044
+ # Source of truth
1045
+
1046
+ Source code wins. If the context map disagrees with the code:
1047
+
1048
+ 1. Investigate the discrepancy.
1049
+ 2. Trust the source.
1050
+ 3. Correct the map.
1051
+
1052
+ Never propagate stale context. Never edit source code to match the map \u2014 you
1053
+ never edit source code at all.
1054
+
1055
+ # Confidence
1056
+
1057
+ Do not record assumptions as facts. If something is uncertain, mark it inline:
1058
+
1059
+ > Confidence: low \u2014 inferred from \`src/foo.ts\` naming only
1060
+
1061
+ Intent is the most common thing code cannot tell you. A single \`export { app }\`
1062
+ does not prove a test seam was intended, and one carefully written expression
1063
+ does not prove a repository convention. Mark those inferences or leave them
1064
+ out.
1065
+
1066
+ If a fact cannot be established with reasonable effort, omit it. A short map
1067
+ that is true beats a long map that is partly guesswork.
1068
+
1069
+ # Keep it small
1070
+
1071
+ The context system exists to REDUCE context consumption. Never let the map
1072
+ grow into an encyclopedia. Do not write:
1073
+
1074
+ - file inventories or directory listings
1075
+ - copied or paraphrased source code
1076
+ - large API descriptions
1077
+ - content duplicated from the project's own docs \u2014 link to them instead
1078
+ - generated documentation
1079
+ - exhaustive class or function lists
1080
+ - the same finding restated in several files \u2014 state a fact once, in the file
1081
+ that owns it, and let the others point at it
1082
+ - generic programming advice that would be true in any repository
1083
+
1084
+ Write conclusions, not inventories. Prefer a statement like
1085
+ "\`OrderService\` is the application boundary for order mutations" over a
1086
+ description of every method it exposes.
1087
+
1088
+ If a file grows past its target, consolidate: merge overlapping entries, drop
1089
+ the lowest-value ones, and link out to authoritative project docs.
1090
+
1091
+ # Review mode
1092
+
1093
+ Trigger: an explicit review (see the \`context-review\` command).
1094
+
1095
+ Audit the five files for:
1096
+
1097
+ - stale information \u2014 verify claims against the code
1098
+ - contradictions between files, or between the map and the code
1099
+ - excessive size \u2014 consolidate toward the targets above
1100
+ - duplicated information \u2014 one canonical place per fact
1101
+ - missing canonical entry points for significant subsystems
1102
+ - unsupported assumptions \u2014 anything unmarked that you cannot verify
1103
+
1104
+ Fix problems only in \`.opencode/context/\`. Never modify source code during a
1105
+ review. Report what you found, what you fixed, and what you could not verify.
1106
+
1107
+ # Reporting
1108
+
1109
+ Finish with a short report:
1110
+
1111
+ - what you inspected
1112
+ - what you created or changed, and why it matters for future agents
1113
+ - what you deliberately left out
1114
+ - anything you found that contradicts the code or the existing docs`,
1115
+ permission: {
1116
+ "read": "allow",
1117
+ "glob": "allow",
1118
+ "grep": "allow",
1119
+ "list": "allow",
1120
+ "edit": {
1121
+ "*": "deny",
1122
+ "*/.opencode/context/**": "allow",
1123
+ ".opencode/context/**": "allow"
1124
+ },
1125
+ "bash": {
1126
+ "*": "deny",
1127
+ "ls": "allow",
1128
+ "ls *": "allow",
1129
+ "pwd": "allow",
1130
+ "cat *": "allow",
1131
+ "find *": "allow",
1132
+ "fd *": "allow",
1133
+ "tree *": "allow",
1134
+ "wc *": "allow",
1135
+ "head *": "allow",
1136
+ "tail *": "allow",
1137
+ "stat *": "allow",
1138
+ "file *": "allow",
1139
+ "du *": "allow",
1140
+ "git status*": "allow",
1141
+ "git log*": "allow",
1142
+ "git ls-files*": "allow",
1143
+ "git show*": "allow",
1144
+ "git diff*": "allow",
1145
+ "git blame*": "allow",
1146
+ "git grep*": "allow",
1147
+ "grep *": "allow",
1148
+ "rg *": "allow",
1149
+ "rg": "allow",
1150
+ "echo *": "allow",
1151
+ "sort*": "allow",
1152
+ "uniq*": "allow",
1153
+ "wc": "allow",
1154
+ "which *": "allow",
1155
+ "date": "allow",
1156
+ "mkdir -p *": "deny"
1157
+ },
1158
+ "external_directory": {
1159
+ "*": "deny",
1160
+ "$HOME/.local/share/opencode/tool-output/*": "allow"
1161
+ },
1162
+ "webfetch": "deny",
1163
+ "websearch": "deny",
1164
+ "question": "deny",
1165
+ "task": {
1166
+ "*": "deny"
1167
+ }
1168
+ }
1169
+ }
1170
+ };
1171
+ function buildAgentSet() {
1172
+ const set = { ...AGENTS };
1173
+ for (const base of POOLED_BASES) {
1174
+ const def = AGENTS[base];
1175
+ if (!def) continue;
1176
+ for (let slot = 1; slot <= MAX_SLOTS; slot++) {
1177
+ const variant = {
1178
+ ...def,
1179
+ 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.`,
1180
+ hidden: true
1181
+ };
1182
+ delete variant.model;
1183
+ set[`${base}-${slot}`] = variant;
1184
+ }
1185
+ }
1186
+ return set;
1187
+ }
1188
+ var DEEP_TIER_SET = new Set(DEEP_TIERS);
1189
+ function applyAgents(cfg, agents = buildAgentSet(), opts = {}) {
1190
+ const target = cfg.agent ??= {};
1191
+ const added = [];
1192
+ const kept = [];
1193
+ const deepModel = typeof opts.deepModel === "string" ? opts.deepModel.trim() : "";
1194
+ for (const [name, def] of Object.entries(agents)) {
1195
+ if (target[name] !== void 0) {
1196
+ kept.push(name);
1197
+ continue;
1198
+ }
1199
+ const copy = JSON.parse(JSON.stringify(def));
1200
+ if (deepModel && DEEP_TIER_SET.has(name) && !copy.model) copy.model = deepModel;
1201
+ target[name] = copy;
1202
+ added.push(name);
1203
+ }
1204
+ return { added, kept };
1205
+ }
1206
+
1207
+ // src/commands.ts
1208
+ var COMMANDS = {
1209
+ "context-init": {
1210
+ description: "Bootstrap the repository context map (.opencode/context/) via the context-manager agent.",
1211
+ agent: "context-manager",
1212
+ subtask: true,
1213
+ template: `Bootstrap the repository context map.
1214
+
1215
+ If \`.opencode/context/\` already exists, do NOT start over \u2014 verify and refresh
1216
+ it in place (correct stale entries, fill gaps) using the review procedure in
1217
+ your system prompt.
1218
+
1219
+ Otherwise follow the bootstrap procedure in your system prompt: progressive
1220
+ exploration (README \u2192 existing docs \u2192 manifests \u2192 top-level structure \u2192 build
1221
+ and framework config \u2192 entry points \u2192 selective source), then write the five
1222
+ context files inside their size targets.
1223
+
1224
+ Remember: write conclusions, not inventories. Link to the project's existing
1225
+ documentation instead of duplicating it.
1226
+
1227
+ Report what you created or updated, what you deliberately left out, and
1228
+ anything that contradicts the code or existing docs.`
1229
+ },
1230
+ "context-update": {
1231
+ description: "Incrementally update .opencode/context/ after recent work via the context-manager agent.",
1232
+ agent: "context-manager",
1233
+ subtask: true,
1234
+ template: `Incremental context update.
1235
+
1236
+ Focus: $ARGUMENTS
1237
+
1238
+ If no focus was given, establish the recent work yourself (git status, git
1239
+ diff, git log) and start from those changed files plus their immediate
1240
+ architectural neighborhood.
1241
+
1242
+ Follow the incremental-update procedure in your system prompt: update a
1243
+ context file only if this work introduced or revealed information that will
1244
+ materially help future agents \u2014 a new boundary or module, a new or moved
1245
+ canonical entry point, a changed workflow, a new convention, an architectural
1246
+ decision, a new dependency between subsystems, a renamed or moved subsystem,
1247
+ or a previously undocumented invariant.
1248
+
1249
+ Do not edit documentation just because source files changed. No
1250
+ timestamp-only edits. If nothing material changed, make no edits and report
1251
+ "no update needed".`
1252
+ },
1253
+ "context-review": {
1254
+ description: "Audit .opencode/context/ for staleness, contradictions, excess size, duplication, and missing entry points.",
1255
+ agent: "context-manager",
1256
+ subtask: true,
1257
+ template: `Context review.
1258
+
1259
+ Everything outside \`.opencode/context/\` is read-only for this run. Do not
1260
+ modify source code under any circumstances.
1261
+
1262
+ Audit the five context files for:
1263
+
1264
+ - stale information \u2014 verify claims against the actual code
1265
+ - contradictions between context files, or between the map and the code
1266
+ - excessive size \u2014 consolidate toward the per-file targets in your system
1267
+ prompt
1268
+ - duplicated information \u2014 one canonical place per fact; link to the
1269
+ project's own docs rather than restating them
1270
+ - missing canonical entry points for significant subsystems
1271
+ - unsupported assumptions \u2014 anything stated as fact that you cannot verify,
1272
+ or that lacks a confidence marker
1273
+
1274
+ Fix what you find in \`.opencode/context/\` only, then report: findings, fixes,
1275
+ and anything you could not verify.`
1276
+ }
1277
+ };
1278
+ function applyCommands(cfg, commands = COMMANDS) {
1279
+ const target = cfg.command ??= {};
1280
+ const added = [];
1281
+ const kept = [];
1282
+ for (const [name, def] of Object.entries(commands)) {
1283
+ if (target[name] !== void 0) {
1284
+ kept.push(name);
1285
+ continue;
1286
+ }
1287
+ target[name] = JSON.parse(JSON.stringify(def));
1288
+ added.push(name);
1289
+ }
1290
+ return { added, kept };
1291
+ }
1292
+ var STANDING_INSTRUCTION = `## Repository context map
1293
+
1294
+ This project may keep a navigation map in \`.opencode/context/\`. Before broad
1295
+ exploration, check whether it exists (\`ls .opencode/context\` \u2014 never the \`glob\`
1296
+ tool, which skips hidden directories). If it does, read only the file relevant to
1297
+ the task \u2014 architecture.md, contexts.md, conventions.md, workflows.md,
1298
+ decisions.md \u2014 then start from the canonical entry points it lists.
1299
+
1300
+ Source code is authoritative: if the map disagrees with the code, trust the code,
1301
+ say so, and never edit source to match the map. Never edit \`.opencode/context/\`
1302
+ yourself \u2014 the context-manager agent owns it. Say in your final report if your
1303
+ work revealed something that belongs in the map.`;
1304
+ function applyInstructions(cfg) {
1305
+ const existing = cfg.instructions;
1306
+ if (Array.isArray(existing) && existing.length > 0) return { added: false };
1307
+ cfg.instructions = [STANDING_INSTRUCTION];
1308
+ return { added: true };
1309
+ }
1310
+
1311
+ // src/plugins/agent-pool.ts
1312
+ import { tool } from "@opencode-ai/plugin";
1313
+ var SERVICE = "agent-pool";
1314
+ var REAP_INTERVAL_MS = 3e4;
1315
+ var HANG_FALLBACK_MS = 10 * 60 * 1e3;
1316
+ var agentPoolHooks = (async ({ client, serverUrl }, options) => {
1317
+ const opts = options ?? {};
1318
+ const s = state(opts);
1319
+ ensureUserConfigDir();
1320
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE, level, message, extra } }).catch(() => {
1321
+ });
1322
+ const warnOnce = async (message) => {
1323
+ if (s.warned.has(message)) return;
1324
+ s.warned.add(message);
1325
+ await log("warn", message);
1326
+ };
1327
+ init(s, (m) => void warnOnce(m));
1328
+ const abortSession = async (sessionID) => {
1329
+ if (!serverUrl) return false;
1330
+ try {
1331
+ await fetch(new URL(`/session/${sessionID}/abort`, serverUrl).toString(), { method: "POST" });
1332
+ return true;
1333
+ } catch {
1334
+ return false;
1335
+ }
1336
+ };
1337
+ const reapStuck = async () => {
1338
+ if (s.reaping) return;
1339
+ s.reaping = true;
1340
+ s.lastSweep = Date.now();
1341
+ try {
1342
+ const stuck = findStuck(s);
1343
+ if (stuck.length === 0) return;
1344
+ releaseStuck(s, stuck.map((x) => x.key));
1345
+ for (const { claim } of stuck) {
1346
+ const killed = claim.g ? await abortSession(claim.g) : false;
1347
+ s.reaped++;
1348
+ await log(
1349
+ "warn",
1350
+ `hung ${claim.v} on ${claim.m} after ${Math.round((Date.now() - claim.t) / 1e3)}s with ${Math.round(idleFor(s, claim, Date.now()) / 1e3)}s of silence; ${killed ? `aborted session ${claim.g}` : "no session to abort"}`,
1351
+ { base: claim.v, model: claim.m, slot: claim.k, session: claim.g, aborted: killed }
1352
+ );
1353
+ const prior = s.cooling.get(claim.m);
1354
+ const now = Date.now();
1355
+ const strike = recordStrike(s, claim.m, "hang", prior && prior.until > now ? prior.until - now : HANG_FALLBACK_MS);
1356
+ if (strike) await log("warn", `cooling ${claim.m} for ${Math.round((strike.until - now) / 1e3)}s (hang)`);
1357
+ }
1358
+ } catch (error) {
1359
+ await log("error", `reaper failed: ${String(error)}`);
1360
+ } finally {
1361
+ s.reaping = false;
1362
+ }
1363
+ };
1364
+ refreshCatalog(s, serverUrl);
1365
+ const reapMs = opts.reapMs ?? REAP_INTERVAL_MS;
1366
+ void reapStuck();
1367
+ const timer = setInterval(() => void reapStuck(), reapMs);
1368
+ timer.unref?.();
1369
+ void log(
1370
+ "info",
1371
+ `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`
1372
+ );
1373
+ return {
1374
+ config: async (cfg) => {
1375
+ const slots = ensureSlots(s, (m) => void warnOnce(m));
1376
+ const agents = cfg.agent;
1377
+ const providers = cfg.provider;
1378
+ const known = /* @__PURE__ */ new Set();
1379
+ for (const [name, def] of Object.entries(cfg.agent ?? {})) if (def?.model) s.models.set(name, def.model);
1380
+ for (const slot of slots) {
1381
+ for (const base of BASES) {
1382
+ const name = agentFor(base, slot.index);
1383
+ const def = agents?.[name];
1384
+ if (!def) {
1385
+ await warnOnce(`variant ${name} is missing from the config; spawns of ${base} that route to slot ${slot.index} would fail`);
1386
+ continue;
1387
+ }
1388
+ if (def.model && def.model !== slot.model) {
1389
+ await warnOnce(`${name} still hard-codes ${def.model}; the resolved pool preset wins (${slot.model}). Delete the line to keep one source of truth.`);
1390
+ }
1391
+ def.model = slot.model;
1392
+ known.add(name);
1393
+ s.models.set(name, slot.model);
1394
+ }
1395
+ const slash = slot.model.indexOf("/");
1396
+ const provider = slot.model.slice(0, slash);
1397
+ const id = slot.model.slice(slash + 1);
1398
+ const entry = providers?.[provider];
1399
+ if (!entry) {
1400
+ await warnOnce(`provider ${provider} is not configured in opencode.jsonc; slot ${slot.index} (${slot.model}) cannot resolve`);
1401
+ continue;
1402
+ }
1403
+ const list = entry.whitelist;
1404
+ if (list && !list.includes(id)) {
1405
+ list.push(id);
1406
+ await log("info", `whitelisted ${id} on ${provider} (${s.origin} slot ${slot.index})`);
1407
+ }
1408
+ }
1409
+ const deepModel = typeof opts.deepModel === "string" ? opts.deepModel.trim() : "";
1410
+ const deepSlash = deepModel.indexOf("/");
1411
+ if (deepModel && deepSlash > 0) {
1412
+ const provider = deepModel.slice(0, deepSlash);
1413
+ const id = deepModel.slice(deepSlash + 1);
1414
+ const entry = providers?.[provider];
1415
+ if (!entry) {
1416
+ await warnOnce(`deepModel ${deepModel} names provider ${provider}, which is not configured in opencode.jsonc; explore-deep and implement-deep cannot resolve it`);
1417
+ } else {
1418
+ const list = entry.whitelist;
1419
+ if (list && !list.includes(id)) {
1420
+ list.push(id);
1421
+ await log("info", `whitelisted ${id} on ${provider} (deepModel)`);
1422
+ }
1423
+ }
1424
+ } else if (deepModel) {
1425
+ await warnOnce(`deepModel ${deepModel} is not in provider/model-id form and was ignored`);
1426
+ }
1427
+ s.known = known;
1428
+ await log(
1429
+ "info",
1430
+ `${s.origin}: bound ${known.size} variant agent(s) to ${slots.length} slot(s): ${slots.map((sl) => `${sl.index}=${sl.model} w${sl.weight}`).join(", ")}`
1431
+ );
1432
+ },
1433
+ event: async ({ event }) => {
1434
+ try {
1435
+ const props = event.properties ?? {};
1436
+ if (event.type === "session.status") {
1437
+ const status2 = props.status;
1438
+ if (status2?.type === "retry" && status2.action?.reason === "free_tier_limit" && status2.action.provider) {
1439
+ const provider = status2.action.provider;
1440
+ const until = typeof status2.next === "number" && status2.next > Date.now() ? status2.next : Date.now() + HANG_FALLBACK_MS;
1441
+ const cooled = coolProvider(s, provider, until, "free_tier_limit");
1442
+ if (cooled.length > 0) {
1443
+ await log("warn", `free tier exhausted on provider ${provider}; cooling ${cooled.length} slot(s) until ${new Date(until).toISOString().slice(11, 19)}Z`, {
1444
+ provider,
1445
+ until,
1446
+ models: cooled.map((c) => c.model)
1447
+ });
1448
+ }
1449
+ }
1450
+ return;
1451
+ }
1452
+ if (event.type === "session.created") {
1453
+ const info2 = props.info;
1454
+ if (info2?.id && info2.parentID) bindChild(s, info2.parentID, info2.id);
1455
+ return;
1456
+ }
1457
+ if (event.type === "message.updated" || event.type === "message.part.updated") {
1458
+ const info2 = props.info;
1459
+ const part = props.part;
1460
+ const sessionID = info2?.sessionID ?? part?.sessionID;
1461
+ if (sessionID) noteActivity(s, sessionID);
1462
+ }
1463
+ if (event.type !== "message.updated") return;
1464
+ const info = props.info;
1465
+ if (!info || info.role !== "assistant" || !info.error) return;
1466
+ const haystack = JSON.stringify(info.error) ?? "";
1467
+ const status = typeof info.error.data?.statusCode === "number" ? info.error.data.statusCode : void 0;
1468
+ if (!isLimitText(haystack) && status !== 429) return;
1469
+ const model = `${info.providerID ?? "?"}/${info.modelID ?? "?"}`;
1470
+ const stated = statedCooldown(haystack) ?? retryAfterMs(info.error.data?.responseHeaders);
1471
+ const reason = haystack.match(/rate[\s_-]?limit|usage limit|too many requests|quota|freelimiterror|overloaded/i)?.[0]?.toLowerCase().replace(/[\s_]/g, "-");
1472
+ const strike = recordStrike(s, model, reason ?? "rate-limited", stated);
1473
+ if (strike) await log("warn", `cooling ${model} for ${Math.round((strike.until - Date.now()) / 1e3)}s (${reason ?? "rate-limited"}, strike ${strike.strikes})`);
1474
+ } catch (error) {
1475
+ await log("error", `limit detection failed: ${String(error)}`);
1476
+ }
1477
+ },
1478
+ "tool.execute.before": async (input, output) => {
1479
+ if (input.tool !== "task") return;
1480
+ try {
1481
+ const requested = output.args?.subagent_type;
1482
+ if (typeof requested !== "string") return;
1483
+ await reapStuck();
1484
+ refreshCatalog(s, serverUrl);
1485
+ const snap = snapshot(s);
1486
+ if (BASES.includes(requested)) {
1487
+ const slot = pickSlot(s, snap);
1488
+ const variant = agentFor(requested, slot.index);
1489
+ if (s.known.size > 0 && !s.known.has(variant)) {
1490
+ await warnOnce(`variant ${variant} missing from config; spawn left unchanged`);
1491
+ return;
1492
+ }
1493
+ acquire(s, requested, slot, input.sessionID, claimKey(input.sessionID, input.callID));
1494
+ output.args.subagent_type = variant;
1495
+ await log("info", `routed ${requested} -> ${variant}`, {
1496
+ slot: slot.index,
1497
+ weight: slot.weight,
1498
+ model: slot.model,
1499
+ ceiling: slot.weight,
1500
+ load: (snap.counts.get(slot.model) ?? 0) + 1
1501
+ });
1502
+ return;
1503
+ }
1504
+ const direct = /^(.*)-([1-9][0-9]*)$/.exec(requested);
1505
+ if (direct && BASES.includes(direct[1])) {
1506
+ const slot = s.slots.find((sl) => sl.index === Number(direct[2]));
1507
+ if (slot) acquire(s, direct[1], slot, input.sessionID, claimKey(input.sessionID, input.callID));
1508
+ }
1509
+ } catch (error) {
1510
+ await log("error", `routing failed, spawn unchanged: ${String(error)}`);
1511
+ }
1512
+ },
1513
+ "tool.execute.after": async (input) => {
1514
+ if (input.tool !== "task") return;
1515
+ release(s, claimKey(input.sessionID, input.callID));
1516
+ },
1517
+ tool: {
1518
+ pool_status: tool({
1519
+ 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.",
1520
+ args: {},
1521
+ async execute() {
1522
+ refreshCatalog(s, serverUrl);
1523
+ const slots = ensureSlots(s, (m) => void warnOnce(m));
1524
+ const snap = snapshot(s);
1525
+ const now = Date.now();
1526
+ const byModel = /* @__PURE__ */ new Map();
1527
+ const add = (model, base) => {
1528
+ const entry = byModel.get(model) ?? { total: 0, bases: /* @__PURE__ */ new Map() };
1529
+ entry.total++;
1530
+ entry.bases.set(base, (entry.bases.get(base) ?? 0) + 1);
1531
+ byModel.set(model, entry);
1532
+ };
1533
+ for (const claim of snap.own.values()) add(claim.m, claim.v);
1534
+ for (const sibling of snap.siblings) for (const claim of sibling.claims) add(claim.m, claim.v);
1535
+ const next = pickSlot(s, snap);
1536
+ const line = (slot) => {
1537
+ const entry = byModel.get(slot.model);
1538
+ const users = entry && entry.bases.size > 0 ? [...entry.bases].map(([b, n]) => `${b}x${n}`).join(" ") : "idle";
1539
+ const strike = snap.cooling.get(slot.model);
1540
+ const status = s.catalog.get(slot.model);
1541
+ const notes = [
1542
+ strike ? `COOLING ${Math.ceil((strike.until - now) / 1e3)}s (${strike.reason}, strike ${strike.strikes})` : "",
1543
+ status && status !== "active" && status !== "unknown" ? `catalog:${status}` : ""
1544
+ ].filter(Boolean);
1545
+ const marker = slot.index === next.index ? " <- next" : "";
1546
+ const load = entry?.total ?? 0;
1547
+ const atCeiling = !underCeiling(slot, snap.counts);
1548
+ return ` slot ${slot.index} w${slot.weight} ${slot.model} load=${load}/${slot.weight}${atCeiling ? " FULL" : ""}${marker} [${users}${notes.length ? "; " + notes.join("; ") : ""}]`;
1549
+ };
1550
+ const total = [...snap.counts.values()].reduce((sum, n) => sum + n, 0);
1551
+ const coolingNow = [...snap.cooling.entries()].filter(([, st]) => st.until > now);
1552
+ const hung = [...snap.own.values()].filter((c) => now - c.t > STUCK_MIN_AGE_MS && idleFor(s, c, now) >= STUCK_IDLE_MS).length;
1553
+ return {
1554
+ title: `agent-pool: ${total} task(s) in flight, ${coolingNow.length} model(s) cooling`,
1555
+ output: [
1556
+ `shared pool over ${slots.length} model(s) from ${s.origin}; bases: ${BASES.join(", ")}
1557
+ priority is weight: the heaviest slot under its ceiling takes the next spawn (FULL = at ceiling, used only when every slot is)`,
1558
+ ...slots.map(line),
1559
+ "",
1560
+ "by process:",
1561
+ ` ${s.id} (this process): ${snap.own.size} claim(s)`,
1562
+ ...snap.siblings.map((sibling) => ` ${sibling.id}: ${sibling.claims.length} claim(s)`),
1563
+ "",
1564
+ `${total} claim(s) in flight across ${snap.siblings.length + 1} process(es).`,
1565
+ `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}.`,
1566
+ `watchdog alive: last sweep ${s.lastSweep ? `${Math.max(0, Math.round((now - s.lastSweep) / 1e3))}s ago` : "never"} (every ${Math.round(reapMs / 1e3)}s).`,
1567
+ coolingNow.length === 0 ? "no models cooling down." : `cooling: ${coolingNow.map(([m, st]) => `${m} for ${Math.ceil((st.until - now) / 1e3)}s`).join(", ")}`
1568
+ ].join("\n")
1569
+ };
1570
+ }
1571
+ })
1572
+ },
1573
+ dispose: async () => {
1574
+ clearInterval(timer);
1575
+ s.own.clear();
1576
+ }
1577
+ };
1578
+ });
1579
+
1580
+ // src/plugins/context-autoupdate.ts
1581
+ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "node:fs";
1582
+ import * as path2 from "node:path";
1583
+
1584
+ // src/lib/writer-rule.ts
1585
+ import { readFileSync as readFileSync2 } from "node:fs";
1586
+ import * as path from "node:path";
1587
+ var CONTEXT_DIR = path.join(".opencode", "context");
1588
+ var WRITER = "context-manager";
1589
+ var toPosix = (value) => value.split(path.sep).join("/");
1590
+ var compilePattern = (pattern) => {
1591
+ const escaped = toPosix(pattern).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1592
+ return new RegExp(`^${escaped}$`, "s");
1593
+ };
1594
+ var winningRule = (rule, file) => {
1595
+ if (typeof rule === "string") return { pattern: "<whole tool>", action: rule };
1596
+ if (!rule || typeof rule !== "object") return void 0;
1597
+ let winner;
1598
+ for (const [pattern, action] of Object.entries(rule)) {
1599
+ if (compilePattern(pattern).test(file)) winner = { pattern, action: String(action) };
1600
+ }
1601
+ return winner;
1602
+ };
1603
+ var stripJsonc = (raw) => {
1604
+ let out = "";
1605
+ let i = 0;
1606
+ while (i < raw.length) {
1607
+ const ch = raw[i];
1608
+ if (ch === '"') {
1609
+ out += ch;
1610
+ i++;
1611
+ while (i < raw.length) {
1612
+ out += raw[i];
1613
+ if (raw[i] === "\\") {
1614
+ out += raw[i + 1] ?? "";
1615
+ i += 2;
1616
+ continue;
1617
+ }
1618
+ if (raw[i] === '"') {
1619
+ i++;
1620
+ break;
1621
+ }
1622
+ i++;
1623
+ }
1624
+ continue;
1625
+ }
1626
+ if (ch === "/" && raw[i + 1] === "/") {
1627
+ while (i < raw.length && raw[i] !== "\n") i++;
1628
+ continue;
1629
+ }
1630
+ if (ch === "/" && raw[i + 1] === "*") {
1631
+ i += 2;
1632
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
1633
+ i += 2;
1634
+ continue;
1635
+ }
1636
+ out += ch;
1637
+ i++;
1638
+ }
1639
+ return out.replace(/,(\s*[}\]])/g, "$1");
1640
+ };
1641
+ var readAgents = (raw) => {
1642
+ try {
1643
+ const agents = JSON.parse(stripJsonc(raw)).agent;
1644
+ if (agents && typeof agents === "object" && !Array.isArray(agents)) return agents;
1645
+ return {};
1646
+ } catch {
1647
+ return void 0;
1648
+ }
1649
+ };
1650
+ var permissionOf = (agent) => agent?.permission?.edit;
1651
+ var evaluatedForm = (mapDir, projectRoot) => {
1652
+ const absolute = toPosix(path.join(mapDir, "architecture.md"));
1653
+ return projectRoot ? toPosix(path.relative(projectRoot, absolute)) : absolute;
1654
+ };
1655
+ var verifyWriterRule = (configPath, mapDir, projectRoot) => {
1656
+ let raw;
1657
+ try {
1658
+ raw = readFileSync2(configPath, "utf8");
1659
+ } catch {
1660
+ return void 0;
1661
+ }
1662
+ const rules = [];
1663
+ for (const block of raw.matchAll(/"edit"\s*:\s*\{([^}]*)\}/g)) {
1664
+ for (const rule of block[1].matchAll(/"((?:[^"\\]|\\.)*)"\s*:\s*"(allow|ask|deny)"/g)) {
1665
+ if (rule[2] === "allow") rules.push(rule[1].replace(/\\"/g, '"'));
1666
+ }
1667
+ }
1668
+ const form = evaluatedForm(mapDir, projectRoot);
1669
+ if (rules.length > 0) {
1670
+ if (!rules.some((rule) => compilePattern(rule).test(form))) {
1671
+ return `context map is not writable: none of the allow rules in ${path.basename(configPath)} match ` + (projectRoot ? `the project-root-relative form "${form}" that opencode actually evaluates for project root ${toPosix(projectRoot)}` : `the absolute form "${form}"`) + `. Rules seen: ${rules.map((r) => `"${r}"`).join(", ")}. opencode evaluates file permissions against the path RELATIVE TO THE PROJECT ROOT, so "${CONTEXT_DIR}/**" is what fires for a normally-rooted project while "*/${CONTEXT_DIR}/**" only fires when the project root is "/". Allow both forms.`;
1672
+ }
1673
+ }
1674
+ const agents = readAgents(raw);
1675
+ if (agents) {
1676
+ const offenders = [];
1677
+ for (const [name, def] of Object.entries(agents)) {
1678
+ if (name === WRITER || name.startsWith(WRITER)) continue;
1679
+ const win = winningRule(permissionOf(def), form);
1680
+ if (win?.action === "allow") offenders.push(`${name} (edit rule "${win.pattern}": "${win.action}")`);
1681
+ }
1682
+ if (offenders.length > 0) {
1683
+ return `context map has more than one writer: ${offenders.join(", ")} would be granted the map by permission, evaluating "${form}". Only ${WRITER} may write the map. Narrow the grant and put the deny LAST -- opencode takes the LAST matching rule, so "${CONTEXT_DIR}/**": "deny" written after "*": "allow" is what denies it: "*": "allow", "*/${CONTEXT_DIR}/**": "deny", "${CONTEXT_DIR}/**": "deny". Both spellings are required because the evaluated path depends on the project root.`;
1684
+ }
1685
+ }
1686
+ return void 0;
1687
+ };
1688
+
1689
+ // src/plugins/context-autoupdate.ts
1690
+ var SERVICE2 = "context-autoupdate";
1691
+ var CONTEXT_DIR2 = path2.join(".opencode", "context");
1692
+ var COOLDOWN_MS = 10 * 60 * 1e3;
1693
+ var OWNED_TTL_MS = 20 * 60 * 1e3;
1694
+ var MAX_FILES_LISTED = 25;
1695
+ var IGNORED_SEGMENTS = /* @__PURE__ */ new Set([
1696
+ "node_modules",
1697
+ "dist",
1698
+ "dist-test",
1699
+ "out",
1700
+ "build",
1701
+ "coverage",
1702
+ "target",
1703
+ "vendor",
1704
+ "venv",
1705
+ ".venv",
1706
+ "__pycache__",
1707
+ ".next",
1708
+ ".turbo",
1709
+ ".cache",
1710
+ ".git"
1711
+ ]);
1712
+ var toPosix2 = (value) => value.split(path2.sep).join("/");
1713
+ var findProjectConfig = () => {
1714
+ let dir = import.meta.dir ?? path2.dirname(new URL(import.meta.url).pathname);
1715
+ for (let up = 0; up <= 3; up++) {
1716
+ const candidate = path2.join(dir, "opencode.jsonc");
1717
+ if (existsSync2(candidate)) return candidate;
1718
+ const parent = path2.dirname(dir);
1719
+ if (parent === dir) break;
1720
+ dir = parent;
1721
+ }
1722
+ return void 0;
1723
+ };
1724
+ var snapshotMap = (dir) => {
1725
+ const out = /* @__PURE__ */ new Map();
1726
+ try {
1727
+ for (const name of readdirSync2(dir)) {
1728
+ const file = path2.join(dir, name);
1729
+ try {
1730
+ const st = statSync2(file);
1731
+ if (st.isFile()) out.set(name, `${st.size}:${st.mtimeMs}`);
1732
+ } catch {
1733
+ }
1734
+ }
1735
+ } catch {
1736
+ }
1737
+ return out;
1738
+ };
1739
+ var changedSince = (before, dir) => {
1740
+ const after = snapshotMap(dir);
1741
+ let changed = 0;
1742
+ for (const [name, stamp] of after) if (before.get(name) !== stamp) changed++;
1743
+ for (const name of before.keys()) if (!after.has(name)) changed++;
1744
+ return changed;
1745
+ };
1746
+ var contextAutoUpdateHooks = (async ({ client, directory }) => {
1747
+ const pending = /* @__PURE__ */ new Set();
1748
+ const owned = /* @__PURE__ */ new Map();
1749
+ let lastRun = 0;
1750
+ const state2 = state();
1751
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE2, level, message, extra } }).catch(() => {
1752
+ });
1753
+ const root = path2.resolve(directory);
1754
+ const contextAbs = path2.join(root, CONTEXT_DIR2);
1755
+ const config = findProjectConfig();
1756
+ const writerProblem = config ? verifyWriterRule(config, contextAbs, root) : void 0;
1757
+ if (writerProblem) void log("error", writerProblem);
1758
+ const isTracked = (file) => {
1759
+ const absolute = path2.resolve(root, file);
1760
+ if (absolute !== root && !absolute.startsWith(root + path2.sep)) return false;
1761
+ if (absolute === contextAbs || absolute.startsWith(contextAbs + path2.sep)) return false;
1762
+ return !toPosix2(absolute).split("/").some((segment) => IGNORED_SEGMENTS.has(segment));
1763
+ };
1764
+ const listed = (files) => {
1765
+ const shown = files.slice(0, MAX_FILES_LISTED);
1766
+ const rest = files.length - shown.length;
1767
+ const lines = shown.map((file) => `- ${toPosix2(file)}`).join("\n");
1768
+ return rest > 0 ? `${lines}
1769
+ - \u2026and ${rest} more (check git status for the full set)` : lines;
1770
+ };
1771
+ const compose = (mode, files) => mode === "bootstrap" ? [
1772
+ "Bootstrap the repository context map. Project root: " + directory,
1773
+ "",
1774
+ "`.opencode/context/` does not exist yet.",
1775
+ "",
1776
+ "This session touched these paths (starting context, not an exhaustive list):",
1777
+ listed(files),
1778
+ "",
1779
+ "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."
1780
+ ].join("\n") : [
1781
+ "Incremental context update. Project root: " + directory,
1782
+ "",
1783
+ "This session touched these paths (start from these and their immediate architectural neighborhood):",
1784
+ listed(files),
1785
+ "",
1786
+ '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".'
1787
+ ].join("\n");
1788
+ return {
1789
+ event: async ({ event }) => {
1790
+ try {
1791
+ if (event.type === "file.edited") {
1792
+ if (isTracked(event.properties.file)) pending.add(event.properties.file);
1793
+ return;
1794
+ }
1795
+ if (event.type !== "session.idle") return;
1796
+ const sessionID = event.properties.sessionID;
1797
+ if (owned.delete(sessionID)) {
1798
+ lastRun = Date.now();
1799
+ return;
1800
+ }
1801
+ const now = Date.now();
1802
+ for (const [id, startedAt] of owned) if (now - startedAt > OWNED_TTL_MS) owned.delete(id);
1803
+ if (owned.size > 0) return;
1804
+ if (Date.now() - lastRun < COOLDOWN_MS) return;
1805
+ if (pending.size === 0) return;
1806
+ const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
1807
+ if (!info) return;
1808
+ if (info.parentID) return;
1809
+ const files = [...pending];
1810
+ for (const file of files) pending.delete(file);
1811
+ const requeue = () => {
1812
+ for (const file of files) pending.add(file);
1813
+ };
1814
+ const mode = existsSync2(contextAbs) ? "update" : "bootstrap";
1815
+ init(state2, (m) => void log("warn", m));
1816
+ const snap = snapshot(state2);
1817
+ const slot = pickSlot(state2, snap);
1818
+ const agent = agentFor("context-manager", slot.index);
1819
+ const created = await client.session.create({
1820
+ body: { title: `context-manager (auto ${mode})` },
1821
+ query: { directory }
1822
+ });
1823
+ const target = created.data?.id;
1824
+ if (!target) {
1825
+ requeue();
1826
+ await log("warn", "could not create context-manager session");
1827
+ return;
1828
+ }
1829
+ const claimKey2 = claimKey(target, "context-autoupdate");
1830
+ acquire(state2, "context-manager", slot, target, claimKey2, target);
1831
+ await log("info", `borrowed pool slot ${slot.index} (${slot.model}, w${slot.weight}) for auto ${mode}`, {
1832
+ slot: slot.index,
1833
+ weight: slot.weight,
1834
+ model: slot.model
1835
+ });
1836
+ lastRun = Date.now();
1837
+ owned.set(target, Date.now());
1838
+ const before = snapshotMap(contextAbs);
1839
+ client.session.prompt({
1840
+ path: { id: target },
1841
+ query: { directory },
1842
+ body: { agent, parts: [{ type: "text", text: compose(mode, files) }] }
1843
+ }).then(async (result) => {
1844
+ release(state2, claimKey2);
1845
+ const failure = result.error ?? result.data?.info?.error;
1846
+ if (failure) {
1847
+ requeue();
1848
+ await log("warn", `auto ${mode} did not complete`, {
1849
+ sessionID: target,
1850
+ files: files.length,
1851
+ error: failure.name ?? String(failure)
1852
+ });
1853
+ return;
1854
+ }
1855
+ const changed = changedSince(before, contextAbs);
1856
+ if (changed > 0) {
1857
+ await log("info", `auto ${mode} finished`, { sessionID: target, considered: files.length, changed });
1858
+ } else {
1859
+ await log("warn", `auto ${mode} finished without changing the map`, {
1860
+ sessionID: target,
1861
+ considered: files.length,
1862
+ changed: 0,
1863
+ hint: "either nothing needed updating, or the cartographer could not write -- check the one-writer permission rule"
1864
+ });
1865
+ }
1866
+ }).catch(async (error) => {
1867
+ release(state2, claimKey2);
1868
+ requeue();
1869
+ await log("error", `auto ${mode} failed: ${String(error)}`);
1870
+ });
1871
+ } catch (error) {
1872
+ await log("error", `handler failed: ${String(error)}`);
1873
+ }
1874
+ }
1875
+ };
1876
+ });
1877
+
1878
+ // src/plugins/notify.ts
1879
+ import { spawn } from "node:child_process";
1880
+ import { existsSync as existsSync3 } from "node:fs";
1881
+ import * as path3 from "node:path";
1882
+
1883
+ // src/lib/notify.ts
1884
+ var INTERNAL_TITLE_PREFIXES = [
1885
+ // context-autoupdate spawns the cartographer through the session API, with no
1886
+ // parentID, so it looks exactly like a root session going idle. Notifying for
1887
+ // it would announce the cartographer's own bookkeeping as a finished task.
1888
+ "context-manager (auto"
1889
+ ];
1890
+ var notifyMessage = (outcome, detail) => {
1891
+ if (outcome === "error") return detail ? `Failed \u2014 ${detail}` : "Failed";
1892
+ if (outcome === "question") return detail ? `Awaiting your response \u2014 ${detail}` : "Awaiting your response";
1893
+ if (outcome === "permission") return detail ? `Awaiting your permission \u2014 ${detail}` : "Awaiting your permission";
1894
+ return "Task finished, waiting for your review";
1895
+ };
1896
+ var safeTitle = (raw, fallback = "session") => {
1897
+ if (!raw) return fallback;
1898
+ const clean = raw.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
1899
+ if (!clean) return fallback;
1900
+ return clean.length > 90 ? `${clean.slice(0, 89)}\u2026` : clean;
1901
+ };
1902
+ var shouldNotify = (input) => {
1903
+ const { info, outcome, detail } = input;
1904
+ if (!info?.id) return { notify: false, reason: "no session info" };
1905
+ if (info.parentID) return { notify: false, reason: "subagent" };
1906
+ const title = safeTitle(info.title);
1907
+ if (INTERNAL_TITLE_PREFIXES.some((prefix) => title.startsWith(prefix))) {
1908
+ return { notify: false, reason: "internal session" };
1909
+ }
1910
+ if (info.time?.compacting) return { notify: false, reason: "compacting" };
1911
+ if (outcome === "done" && input.sawMessage === false) {
1912
+ return { notify: false, reason: "no work done" };
1913
+ }
1914
+ if (outcome === "done" && input.awaitingResponse) {
1915
+ return { notify: false, reason: "awaiting your response" };
1916
+ }
1917
+ return {
1918
+ notify: true,
1919
+ title: `opencode \u2014 ${title}`,
1920
+ message: notifyMessage(outcome, detail),
1921
+ urgency: outcome === "error" ? "critical" : "normal"
1922
+ };
1923
+ };
1924
+ var questionHeadline = (questions) => {
1925
+ const first = questions?.find((entry) => typeof entry?.question === "string" && entry.question.trim());
1926
+ if (!first?.question) return "";
1927
+ const clean = safeTitle(first.question);
1928
+ return clean.length > 70 ? `${clean.slice(0, 69)}\u2026` : clean;
1929
+ };
1930
+ var str = (value) => typeof value === "string" && value.trim() ? value : void 0;
1931
+ var permissionKind = (request) => str(request.permission) ?? str(request.type) ?? "";
1932
+ var permissionPatterns = (request) => {
1933
+ const list = Array.isArray(request.patterns) ? request.patterns : Array.isArray(request.pattern) ? request.pattern : [request.pattern];
1934
+ return list.filter((entry) => typeof entry === "string" && entry.length > 0);
1935
+ };
1936
+ var trimGlob = (raw) => {
1937
+ const star = raw.indexOf("*");
1938
+ return (star === -1 ? raw : raw.slice(0, star)).replace(/[\\/]+$/, "");
1939
+ };
1940
+ var shortenHome = (raw) => {
1941
+ const home = process.env.HOME ?? "";
1942
+ return home && raw.startsWith(`${home}/`) ? `~${raw.slice(home.length)}` : raw;
1943
+ };
1944
+ var permissionHeadline = (request) => {
1945
+ const kind = permissionKind(request);
1946
+ const patterns = permissionPatterns(request);
1947
+ const metadata = request.metadata ?? {};
1948
+ const meta = (key) => str(metadata[key]);
1949
+ const requested = shortenHome(trimGlob(meta("parentDir") ?? meta("filepath") ?? patterns[0] ?? ""));
1950
+ if (kind === "external_directory") {
1951
+ return requested ? `access external directory ${requested}` : "access an external directory";
1952
+ }
1953
+ const command = meta("command");
1954
+ if (kind === "bash" || kind === "shell") {
1955
+ return command ? `run \`${safeTitle(command, "a shell command")}\`` : "run a shell command";
1956
+ }
1957
+ const label = kind || "a tool";
1958
+ if (requested) return `${label} \u2014 ${requested}`;
1959
+ if (patterns.length) return `${label} \u2014 ${patterns.join(", ")}`;
1960
+ return `${label} is asking for permission`;
1961
+ };
1962
+ var MAX_TRACKED = 500;
1963
+ var Notifier = class {
1964
+ lastSent = /* @__PURE__ */ new Map();
1965
+ // Not a constructor parameter property: this module is loaded by node's
1966
+ // type-stripping loader, which does not support the shorthand.
1967
+ windowMs;
1968
+ constructor(windowMs) {
1969
+ this.windowMs = windowMs;
1970
+ }
1971
+ allow(sessionID, now) {
1972
+ const previous = this.lastSent.get(sessionID);
1973
+ if (previous !== void 0 && now - previous < this.windowMs) return false;
1974
+ this.record(sessionID, now);
1975
+ return true;
1976
+ }
1977
+ // Record without asking. Used by the error path, which must bypass the window
1978
+ // rather than be blocked by it, but still needs the timestamp refreshed.
1979
+ record(sessionID, now) {
1980
+ this.lastSent.set(sessionID, now);
1981
+ this.evict(now);
1982
+ }
1983
+ evict(now) {
1984
+ if (this.lastSent.size <= MAX_TRACKED) return;
1985
+ let excess = this.lastSent.size - MAX_TRACKED;
1986
+ for (const key of this.lastSent.keys()) {
1987
+ if (excess-- <= 0) break;
1988
+ this.lastSent.delete(key);
1989
+ }
1990
+ }
1991
+ // Exposed so the eviction test can assert on it: the map is otherwise
1992
+ // unreachable from outside, which would make the bound untestable.
1993
+ lastSentSize() {
1994
+ return this.lastSent.size;
1995
+ }
1996
+ };
1997
+
1998
+ // src/plugins/notify.ts
1999
+ var SERVICE3 = "notify";
2000
+ var COOLDOWN_MS2 = 5e3;
2001
+ var findHelper = () => {
2002
+ const home = process.env.HOME ?? "";
2003
+ const candidates = [
2004
+ process.env.OC_NOTIFY_HELPER,
2005
+ path3.join(home, "bin", "lacode"),
2006
+ path3.join(home, "bin", "oc-notify"),
2007
+ path3.join(PACKAGE_ROOT, "bin", "oc-notify"),
2008
+ path3.join(PACKAGE_ROOT, "dist", "lacode.js"),
2009
+ path3.join(PACKAGE_ROOT, "bin", "lacode")
2010
+ ].filter((candidate) => typeof candidate === "string" && candidate.length > 0);
2011
+ for (const candidate of candidates) if (existsSync3(candidate)) return candidate;
2012
+ return void 0;
2013
+ };
2014
+ var notifyHooks = (async ({ client, directory }, _options, deps = {}) => {
2015
+ const notifier = new Notifier(COOLDOWN_MS2);
2016
+ const active = /* @__PURE__ */ new Set();
2017
+ const titles = /* @__PURE__ */ new Map();
2018
+ const pending = /* @__PURE__ */ new Set();
2019
+ const permissions = /* @__PURE__ */ new Set();
2020
+ let warnedHelper = false;
2021
+ const log = (level, message, extra) => client.app.log({ body: { service: SERVICE3, level, message, extra } }).catch(() => {
2022
+ });
2023
+ const fetchInfo = async (sessionID) => {
2024
+ const info = await client.session.get({ path: { id: sessionID }, query: { directory } }).then((result) => result.data).catch(() => void 0);
2025
+ return info;
2026
+ };
2027
+ const deliverViaHelper = (title, message, urgency) => new Promise((resolve2) => {
2028
+ const helper = findHelper();
2029
+ if (!helper) {
2030
+ if (!warnedHelper) {
2031
+ warnedHelper = true;
2032
+ void log("warn", "no oc-notify helper found; run 'bin/oc-sync' to install one");
2033
+ }
2034
+ resolve2({ ok: false, detail: "no oc-notify helper found" });
2035
+ return;
2036
+ }
2037
+ const child = spawn(helper, [title, message, urgency, "utilities-terminal"], {
2038
+ stdio: ["ignore", "ignore", "pipe"],
2039
+ detached: true
2040
+ });
2041
+ let stderr = "";
2042
+ child.stderr?.on("data", (chunk) => {
2043
+ if (stderr.length < 400) stderr += String(chunk);
2044
+ });
2045
+ child.on("error", (error) => {
2046
+ void log("warn", `notification helper failed: ${String(error)}`, { title });
2047
+ resolve2({ ok: false, detail: String(error) });
2048
+ });
2049
+ child.on("exit", (code) => {
2050
+ const noise = stderr.trim();
2051
+ const ok = code === 0 && noise === "";
2052
+ if (!ok) {
2053
+ void log("warn", `notification not delivered: ${noise || `helper exit ${code}`}`, {
2054
+ title,
2055
+ message
2056
+ });
2057
+ }
2058
+ resolve2({ ok, detail: noise || void 0 });
2059
+ });
2060
+ child.unref();
2061
+ });
2062
+ const deliver = deps.deliver ?? deliverViaHelper;
2063
+ const announce = async (sessionID, outcome, detail) => {
2064
+ try {
2065
+ const info = await fetchInfo(sessionID);
2066
+ if (!info) {
2067
+ void log("debug", "session info unavailable; not notifying", { sessionID });
2068
+ return;
2069
+ }
2070
+ const titled = info.title ? info : { ...info, title: titles.get(sessionID) };
2071
+ const isRemote = Boolean(process.env.SSH_CLIENT || process.env.SSH_TTY);
2072
+ const decision = shouldNotify({
2073
+ info: titled,
2074
+ outcome,
2075
+ detail,
2076
+ sawMessage: active.has(sessionID),
2077
+ // Either kind of block suppresses the idle event's "finished": a session
2078
+ // parked on a permission prompt has not finished, it is waiting.
2079
+ awaitingResponse: pending.has(sessionID) || permissions.has(sessionID)
2080
+ });
2081
+ if (!decision.notify) {
2082
+ void log("debug", `suppressed: ${decision.reason}`, { sessionID });
2083
+ return;
2084
+ }
2085
+ const kind = decision.urgency === "critical" ? "error" : outcome === "done" ? "done" : outcome;
2086
+ const key = `${kind}:${sessionID}`;
2087
+ const blocking = kind !== "done";
2088
+ const now = Date.now();
2089
+ if (blocking) {
2090
+ notifier.record(key, now);
2091
+ } else if (!notifier.allow(key, now)) {
2092
+ void log("debug", "suppressed: cooldown", { sessionID });
2093
+ return;
2094
+ }
2095
+ const message = isRemote ? `${decision.message} (remote)` : decision.message;
2096
+ const sent = await deliver(decision.title, message, decision.urgency);
2097
+ void log(sent.ok ? "info" : "error", sent.ok ? "notified" : "notification failed", {
2098
+ sessionID,
2099
+ outcome,
2100
+ title: decision.title,
2101
+ ...sent.detail ? { detail: sent.detail } : {}
2102
+ });
2103
+ } catch (error) {
2104
+ void log("error", `notify failed: ${String(error)}`);
2105
+ }
2106
+ };
2107
+ return {
2108
+ event: async ({ event }) => {
2109
+ try {
2110
+ if (event.type === "session.status") {
2111
+ const { sessionID: sessionID2, status } = event.properties;
2112
+ if (status.type === "busy" && sessionID2) active.add(sessionID2);
2113
+ return;
2114
+ }
2115
+ if (event.type === "session.updated") {
2116
+ const info = event.properties.info;
2117
+ if (info?.id) titles.set(info.id, info.title);
2118
+ return;
2119
+ }
2120
+ if (event.type === "question.asked") {
2121
+ const properties = event.properties;
2122
+ const sessionID2 = properties?.sessionID;
2123
+ if (!sessionID2) return;
2124
+ pending.add(sessionID2);
2125
+ active.add(sessionID2);
2126
+ const headline = questionHeadline(properties.questions);
2127
+ void announce(sessionID2, "question", headline || void 0);
2128
+ return;
2129
+ }
2130
+ if (event.type === "question.replied" || event.type === "question.rejected") {
2131
+ const sessionID2 = event.properties?.sessionID;
2132
+ if (sessionID2) pending.delete(sessionID2);
2133
+ return;
2134
+ }
2135
+ if (event.type === "permission.asked") {
2136
+ const properties = event.properties;
2137
+ const sessionID2 = properties?.sessionID;
2138
+ if (!sessionID2) return;
2139
+ permissions.add(sessionID2);
2140
+ active.add(sessionID2);
2141
+ void announce(sessionID2, "permission", permissionHeadline(properties) || void 0);
2142
+ return;
2143
+ }
2144
+ if (event.type === "permission.replied") {
2145
+ const sessionID2 = event.properties?.sessionID;
2146
+ if (sessionID2) permissions.delete(sessionID2);
2147
+ return;
2148
+ }
2149
+ if (event.type === "session.error") {
2150
+ const sessionID2 = event.properties.sessionID;
2151
+ if (sessionID2) {
2152
+ pending.delete(sessionID2);
2153
+ permissions.delete(sessionID2);
2154
+ void announce(sessionID2, "error");
2155
+ }
2156
+ return;
2157
+ }
2158
+ if (event.type === "session.deleted") {
2159
+ const sessionID2 = event.properties?.info?.id;
2160
+ if (sessionID2) {
2161
+ active.delete(sessionID2);
2162
+ titles.delete(sessionID2);
2163
+ pending.delete(sessionID2);
2164
+ permissions.delete(sessionID2);
2165
+ }
2166
+ return;
2167
+ }
2168
+ if (event.type !== "session.idle") return;
2169
+ const sessionID = event.properties.sessionID;
2170
+ if (sessionID) void announce(sessionID, "done");
2171
+ } catch (error) {
2172
+ void log("error", `handler failed: ${String(error)}`);
2173
+ }
2174
+ }
2175
+ };
2176
+ });
2177
+
2178
+ // src/index.ts
2179
+ var inertHooks = {
2180
+ config: async () => {
2181
+ },
2182
+ event: async () => {
2183
+ },
2184
+ dispose: async () => {
2185
+ }
2186
+ };
2187
+ var LaCode = (async (input, options) => {
2188
+ const opts = options ?? {};
2189
+ if (opts.enabled === false) return inertHooks;
2190
+ const log = (level, message, extra) => input.client.app.log({ body: { service: "lacode", level, message, extra } }).catch(() => {
2191
+ });
2192
+ const settled = async (label, make) => {
2193
+ try {
2194
+ return await make();
2195
+ } catch (error) {
2196
+ await log("error", `${label} failed to start: ${String(error)}`);
2197
+ return void 0;
2198
+ }
2199
+ };
2200
+ const agentSet = buildAgentSet();
2201
+ const pool = await settled("agent-pool", () => agentPoolHooks(input, options)) ?? inertHooks;
2202
+ const autoupdate = await settled("context-autoupdate", () => contextAutoUpdateHooks(input, options)) ?? inertHooks;
2203
+ const notifier = await settled("notify", () => notifyHooks(input, options)) ?? inertHooks;
2204
+ const config = async (cfg) => {
2205
+ try {
2206
+ const injected = applyAgents(cfg, agentSet, { deepModel: opts.deepModel });
2207
+ if (injected.added.length > 0) {
2208
+ await log("info", `agents: added ${injected.added.length}, kept ${injected.kept.length} already defined by the user`);
2209
+ }
2210
+ const cmds = applyCommands(cfg);
2211
+ if (cmds.added.length > 0) {
2212
+ await log("info", `commands: added ${cmds.added.join(", ")}; kept ${cmds.kept.join(", ") || "none"} already defined`);
2213
+ }
2214
+ if (applyInstructions(cfg).added) {
2215
+ await log("info", "instructions: added the standing context-map rule (no user instructions were present)");
2216
+ }
2217
+ await pool.config?.(cfg);
2218
+ } catch (error) {
2219
+ await log("error", `config hook failed: ${String(error)}`);
2220
+ }
2221
+ };
2222
+ const event = async (payload) => {
2223
+ await Promise.all([
2224
+ pool.event?.(payload).catch(() => {
2225
+ }),
2226
+ autoupdate.event?.(payload).catch(() => {
2227
+ }),
2228
+ notifier.event?.(payload).catch(() => {
2229
+ })
2230
+ ]);
2231
+ };
2232
+ return {
2233
+ config,
2234
+ event,
2235
+ // Both tool hooks and the tool registry come from the pool alone: it is the
2236
+ // only thing that routes task calls, so there is nothing to merge them with.
2237
+ "tool.execute.before": pool["tool.execute.before"],
2238
+ "tool.execute.after": pool["tool.execute.after"],
2239
+ tool: pool.tool,
2240
+ dispose: async () => {
2241
+ await pool.dispose?.();
2242
+ await autoupdate.dispose?.();
2243
+ await notifier.dispose?.();
2244
+ }
2245
+ };
2246
+ });
2247
+ var index_default = LaCode;
2248
+ export {
2249
+ index_default as default
2250
+ };