@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/LICENSE +32 -0
- package/README.md +415 -2
- package/dist/agents.js +819 -0
- package/dist/commands.js +109 -0
- package/dist/index.js +2250 -0
- package/dist/lacode.js +223 -0
- package/dist/pool.js +487 -0
- package/dist/tunnel.js +150 -0
- package/docs/BENCHMARKS.md +127 -0
- package/docs/CONTEXT.md +211 -0
- package/docs/TUNNEL.md +141 -0
- package/docs/USER-STORIES.md +396 -0
- package/package.json +58 -4
- package/presets/README.md +121 -0
- package/presets/free-tier.json +60 -0
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
|
+
};
|