opencode-harness-kit 0.6.3 → 0.7.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/src/budget.js ADDED
@@ -0,0 +1,418 @@
1
+ // The token budget: per session, and across the sessions that share a window.
2
+ //
3
+ // The free tier is a shared window: every request in every session draws on
4
+ // it, and when it runs out the provider answers with a limit error instead of
5
+ // tokens. Nothing in opencode counts the window, so a long chat session can
6
+ // spend it without ever showing a number - one real session made 599 requests
7
+ // and 12,025,980 input tokens, and the first sign of trouble was the provider
8
+ // refusing the next one.
9
+ //
10
+ // This is the counter. It counts the context a request re-sent: the uncached
11
+ // input, plus the prompt cache it read, plus the cache it wrote. That is the
12
+ // number the provider's window is spent on, and it is NOT `tokens.input`:
13
+ // with prompt caching the uncached part is a few hundred tokens against tens
14
+ // of thousands of `cache.read`, so a counter built on `input` alone undercounts
15
+ // a real session by five to forty times and never fires. Output is capped by the
16
+ // model and is a small fraction of the bill, so it is not counted.
17
+ //
18
+ // The limits are environment variables, not config keys: the guard runs
19
+ // inside opencode, and the environment is the one thing that reaches it
20
+ // without a round trip through the config file.
21
+ //
22
+ // OC_HARNESS_BUDGET_WARN warn once at this many context tokens (default 2M)
23
+ // OC_HARNESS_BUDGET_STOP block tool calls at this many (default 5M)
24
+ // OC_HARNESS_TURN_STOP block after one request re-sent this much
25
+ // (default 300k): the count that catches a loop the
26
+ // per-session total is too large to catch
27
+ //
28
+ // and, for the window every session shares:
29
+ //
30
+ // OC_HARNESS_BUDGET_WINDOW_MINUTES how far back to sum (default 60)
31
+ // OC_HARNESS_BUDGET_WINDOW_WARN warn once at this many (default 4M)
32
+ // OC_HARNESS_BUDGET_WINDOW_STOP block tool calls at this many (default 8M)
33
+ // OC_HARNESS_BUDGET_WINDOW_STICKY `0` lets a stop end when the sum rolls
34
+ // over; default on holds it for 6h
35
+ // OC_HARNESS_BUDGET_FILE the ledger path (tests, benchmarks)
36
+ //
37
+ // Zero disables that threshold. A stop below the warn is not a warn: the warn
38
+ // is clamped down to the stop, so the warning always comes first.
39
+
40
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
41
+ import { homedir } from "node:os"
42
+ import { dirname, join } from "node:path"
43
+
44
+ export const DEFAULT_WARN_TOKENS = 2_000_000
45
+ export const DEFAULT_STOP_TOKENS = 5_000_000
46
+
47
+ // What one request may re-send before it is a loop rather than a turn. A
48
+ // request that re-sends 58,000 tokens costs the same on its fifth retry as on
49
+ // its first, so the per-session total above does not see a loop until it has
50
+ // spent far more than a person can use: the session that prompted this kit
51
+ // made 599 requests at 20,077 tokens each. The per-turn count stops the loop
52
+ // while the ledger still has room, and it is counted PER SESSION, not from the
53
+ // shared ledger: one step-finish reports one request's context, and the
54
+ // ledger's sum over an hour is a different number.
55
+ export const DEFAULT_TURN_STOP_TOKENS = 300_000
56
+
57
+ // The provider's window is not per session and not per project: every session
58
+ // on the account draws on the same one, and it is what actually runs out. A
59
+ // per-session count cannot see that - three parallel sessions each under their
60
+ // own stop still spend three times the budget. These defaults are the shared
61
+ // tripwire, kept in a file every session reads and appends to.
62
+ export const DEFAULT_WINDOW_MINUTES = 60
63
+ export const DEFAULT_WINDOW_WARN_TOKENS = 4_000_000
64
+ export const DEFAULT_WINDOW_STOP_TOKENS = 8_000_000
65
+
66
+ /**
67
+ * A whole, non-negative token count, or the fallback.
68
+ *
69
+ * @param {any} raw
70
+ * @param {number} fallback
71
+ * @returns {number}
72
+ */
73
+ function count(raw, fallback) {
74
+ if (raw === undefined || raw === null || raw === "") return fallback
75
+ const n = Number(raw)
76
+ if (!Number.isFinite(n) || n < 0) return fallback
77
+ return Math.floor(n)
78
+ }
79
+
80
+ /**
81
+ * What one request cost, in context tokens.
82
+ *
83
+ * A `step-finish` part reports four separate numbers: `input` is the part of
84
+ * the prompt that was not cached, `cache.read` is the cached prefix the model
85
+ * still had to read, `cache.write` is the prefix stored for the next request,
86
+ * and `output`/`reasoning` are what the model wrote. The first three are the
87
+ * context; only the first was counted before, which is why a step that cost
88
+ * 37,324 tokens was recorded as 844.
89
+ *
90
+ * `cache.write` is charged once, on the request that stored it, so summing it
91
+ * here is not double counting the reads that follow. Nor does it overlap
92
+ * `input`: across 605 measured steps the reported `total` was exactly
93
+ * `input + cache.read + cache.write + output + reasoning` every time, so the
94
+ * parts are disjoint and the three that are counted are the whole context.
95
+ *
96
+ * A value that is not a number contributes zero: a missing field must not turn
97
+ * the whole count into NaN, because a NaN comparison never fires a threshold.
98
+ *
99
+ * @param {any} tokens a `step-finish` part's `tokens`
100
+ * @returns {number}
101
+ */
102
+ export function requestTokens(tokens) {
103
+ /** @param {unknown} raw @returns {number} */
104
+ const n = (raw) => {
105
+ const x = Number(raw)
106
+ return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0
107
+ }
108
+ return n(tokens?.input) + n(tokens?.cache?.read) + n(tokens?.cache?.write)
109
+ }
110
+
111
+ /**
112
+ * The limits this process should use.
113
+ *
114
+ * @param {Record<string, string | undefined>} [env]
115
+ * @returns {{ warnAt: number, stopAt: number, turnStopAt: number }}
116
+ */
117
+ export function budgetLimits(env = process.env) {
118
+ const warnAt = count(env?.OC_HARNESS_BUDGET_WARN, DEFAULT_WARN_TOKENS)
119
+ const stopAt = count(env?.OC_HARNESS_BUDGET_STOP, DEFAULT_STOP_TOKENS)
120
+ const turnStopAt = count(env?.OC_HARNESS_TURN_STOP, DEFAULT_TURN_STOP_TOKENS)
121
+ return {
122
+ warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
123
+ stopAt,
124
+ turnStopAt,
125
+ }
126
+ }
127
+
128
+ /**
129
+ * A running total for one session.
130
+ *
131
+ * `add` returns the thresholds this call CROSSED, so the caller can warn once
132
+ * rather than once per request. A request that lands exactly on a threshold
133
+ * crosses it, and a request of zero tokens - a retry that never reached the
134
+ * provider - moves nothing.
135
+ *
136
+ * @param {{ warnAt?: number, stopAt?: number }} [limits]
137
+ */
138
+ export function createBudget({ warnAt = DEFAULT_WARN_TOKENS, stopAt = DEFAULT_STOP_TOKENS } = {}) {
139
+ let total = 0
140
+ let warned = false
141
+ let stopped = false
142
+
143
+ return {
144
+ /**
145
+ * @param {any} input tokens this request cost
146
+ * @returns {{ warn: boolean, stop: boolean }}
147
+ */
148
+ add(input) {
149
+ const n = Number(input)
150
+ if (!Number.isFinite(n) || n <= 0) return { warn: false, stop: false }
151
+ total += Math.floor(n)
152
+ let warn = false
153
+ let stop = false
154
+ if (!warned && warnAt > 0 && total >= warnAt) {
155
+ warned = true
156
+ warn = true
157
+ }
158
+ if (!stopped && stopAt > 0 && total >= stopAt) {
159
+ stopped = true
160
+ stop = true
161
+ }
162
+ return { warn, stop }
163
+ },
164
+ get total() {
165
+ return total
166
+ },
167
+ get warned() {
168
+ return warned
169
+ },
170
+ get stopped() {
171
+ return stopped
172
+ },
173
+ }
174
+ }
175
+
176
+ /**
177
+ * A count with separators, for a message a human reads.
178
+ *
179
+ * @param {number} n
180
+ * @returns {string}
181
+ */
182
+ export function tokens(n) {
183
+ return Math.floor(n).toLocaleString("en-US")
184
+ }
185
+
186
+ // --- the shared window ------------------------------------------------------
187
+ //
188
+ // A file, not memory: the sessions that share the window are separate
189
+ // processes, and the one that has to warn is often not the one that spent.
190
+ // The file is a list of `{ t, n }` entries, pruned to the longest window any
191
+ // caller asks about, and written by rename so a reader never sees half a line.
192
+
193
+ /** How long an entry is kept, regardless of the window being asked about. */
194
+ const KEEP_MS = 6 * 60 * 60 * 1000
195
+
196
+ /**
197
+ * Where the shared ledger lives.
198
+ *
199
+ * `XDG_STATE_HOME` is the right place for state that survives a reboot and is
200
+ * not configuration; falling back to `~/.local/state` is what the spec says to
201
+ * do. `OC_HARNESS_BUDGET_FILE` overrides both, which is what the tests use and
202
+ * what a user on a shared account would want.
203
+ *
204
+ * @param {Record<string, string | undefined>} [env]
205
+ * @returns {string}
206
+ */
207
+ export function ledgerPath(env = process.env) {
208
+ const override = env?.OC_HARNESS_BUDGET_FILE
209
+ if (override) return override
210
+ const state = env?.XDG_STATE_HOME || join(env?.HOME || homedir(), ".local", "state")
211
+ return join(state, "opencode-harness-kit", "budget.json")
212
+ }
213
+
214
+ /**
215
+ * The entries in the ledger, or none.
216
+ *
217
+ * Three answers, not two, because "I could not read the count" and "the count
218
+ * is zero" are different facts and only one of them means there is room left:
219
+ *
220
+ * - no file -> `[]`: nothing has been spent yet.
221
+ * - a file that parses -> its entries.
222
+ * - a file that does not parse -> `null`.
223
+ *
224
+ * It used to return `[]` for the third case, which made a corrupt or
225
+ * half-written ledger indistinguishable from an empty window: the shared stop
226
+ * silently disappeared. `sharedWindowSpent()` in `guard.ts` repairs the file
227
+ * instead (see `repairSpend`): a ledger that cannot be read must not lock the
228
+ * window forever, because the only way out is a hand-delete the user may not
229
+ * know about. Nothing here throws.
230
+ *
231
+ * A `stop` entry is a marker, not a spend: `{ t, n: 0, stop: true }` records
232
+ * that the window was locked at `t`, so the lock can outlive the sliding sum
233
+ * that tripped it.
234
+ *
235
+ * @param {string} path
236
+ * @returns {{ t: number, n: number, stop?: boolean }[] | null}
237
+ */
238
+ export function readSpend(path) {
239
+ let raw
240
+ try {
241
+ if (!existsSync(path)) return []
242
+ raw = readFileSync(path, "utf8")
243
+ } catch {
244
+ return null
245
+ }
246
+ let parsed
247
+ try {
248
+ parsed = JSON.parse(raw)
249
+ } catch {
250
+ return null
251
+ }
252
+ if (!Array.isArray(parsed)) return null
253
+ return parsed.filter(
254
+ (e) =>
255
+ e &&
256
+ Number.isFinite(e.t) &&
257
+ Number.isFinite(e.n) &&
258
+ (e.n > 0 || (e.stop === true && e.n === 0)),
259
+ )
260
+ }
261
+
262
+ /**
263
+ * Keep the entries that are still inside the keep window, oldest first.
264
+ *
265
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
266
+ * @param {number} now
267
+ * @returns {{ t: number, n: number, stop?: boolean }[]}
268
+ */
269
+ export function pruneSpend(entries, now) {
270
+ const floor = now - KEEP_MS
271
+ return entries.filter((e) => e.t >= floor).sort((a, b) => a.t - b.t)
272
+ }
273
+
274
+ /**
275
+ * Move an unreadable ledger aside and start a fresh one.
276
+ *
277
+ * The old code read a corrupt file as "spent" and failed closed, which is the
278
+ * right answer for a single tool call and the wrong answer for the machine:
279
+ * the only exit was deleting a file the user had never heard of, so the next
280
+ * session was blocked for six hours by a truncated JSON write. The evidence
281
+ * still matters - a corrupt ledger is a bug worth reading - so the broken file
282
+ * is renamed to `<path>.corrupt` rather than deleted, and an empty ledger
283
+ * replaces it. The count cannot be recovered, so the new file starts at zero;
284
+ * if the old file had a stop marker, `.corrupt` still holds it.
285
+ *
286
+ * Returns false when the repair could not be written; the caller then falls
287
+ * back to the old fail-closed answer rather than pretending the window is
288
+ * empty.
289
+ *
290
+ * @param {string} path
291
+ * @returns {boolean}
292
+ */
293
+ export function repairSpend(path) {
294
+ try {
295
+ if (existsSync(path)) renameSync(path, `${path}.corrupt`)
296
+ writeSpend(path, [])
297
+ return true
298
+ } catch {
299
+ return false
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Write the ledger, atomically.
305
+ *
306
+ * Read-modify-write without a lock: two sessions finishing a step in the same
307
+ * millisecond can lose one entry. That is a few thousand tokens on a budget of
308
+ * millions, and a lock file in this path is a worse failure mode - a crashed
309
+ * session would leave the window blocked for everyone.
310
+ *
311
+ * @param {string} path
312
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
313
+ */
314
+ export function writeSpend(path, entries) {
315
+ const tmp = `${path}.${process.pid}.tmp`
316
+ mkdirSync(dirname(path), { recursive: true })
317
+ writeFileSync(tmp, JSON.stringify(entries))
318
+ renameSync(tmp, path)
319
+ }
320
+
321
+ /**
322
+ * Add one request's cost to the shared ledger.
323
+ *
324
+ * A ledger that cannot be read is repaired first, so the first write after a
325
+ * corruption appends to a fresh file instead of overwriting the broken one.
326
+ * The repair does not invent a stop: the count is unknown, and a marker that
327
+ * holds for six hours is the failure this repair exists to end.
328
+ *
329
+ * @param {string} path
330
+ * @param {number} n
331
+ * @param {number} [now]
332
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
333
+ */
334
+ export function recordSpend(path, n, now = Date.now()) {
335
+ const previous = readSpend(path)
336
+ if (previous === null) repairSpend(path)
337
+ const entries = pruneSpend(previous ?? [], now)
338
+ if (Number.isFinite(n) && n > 0) entries.push({ t: now, n: Math.floor(n) })
339
+ writeSpend(path, entries)
340
+ return entries
341
+ }
342
+
343
+ /**
344
+ * Record that the window was locked, so the lock survives the window it was
345
+ * measured in.
346
+ *
347
+ * The sum is over the last hour, and the provider's window is not: it stayed
348
+ * shut for hours after the stop fired. A lock that releases itself on a timer
349
+ * is a lock that was only partly on, so the marker holds until `KEEP_MS`.
350
+ *
351
+ * @param {string} path
352
+ * @param {number} [now]
353
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
354
+ */
355
+ export function recordStop(path, now = Date.now()) {
356
+ const previous = readSpend(path)
357
+ if (previous === null) repairSpend(path)
358
+ const entries = pruneSpend(previous ?? [], now)
359
+ entries.push({ t: now, n: 0, stop: true })
360
+ writeSpend(path, entries)
361
+ return entries
362
+ }
363
+
364
+ /**
365
+ * Whether a stop marker is still inside the keep window.
366
+ *
367
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
368
+ * @param {number} now
369
+ * @returns {boolean}
370
+ */
371
+ export function stopRecorded(entries, now) {
372
+ const floor = now - KEEP_MS
373
+ return entries.some((e) => e.stop === true && e.t >= floor)
374
+ }
375
+
376
+ /**
377
+ * Tokens spent in the last `windowMs`, across every session on this machine.
378
+ *
379
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
380
+ * @param {number} now
381
+ * @param {number} windowMs
382
+ * @returns {number}
383
+ */
384
+ export function windowSpend(entries, now, windowMs) {
385
+ const floor = now - windowMs
386
+ let total = 0
387
+ for (const e of entries) if (e.t >= floor) total += e.n
388
+ return total
389
+ }
390
+
391
+ /**
392
+ * The shared-window thresholds.
393
+ *
394
+ * `OC_HARNESS_BUDGET_WINDOW_MINUTES` sets the window (default 60). Zero or a
395
+ * value that is not a positive number falls back to the default: a window of
396
+ * zero would sum nothing and silently disable the tripwire.
397
+ *
398
+ * `OC_HARNESS_BUDGET_WINDOW_STICKY` (default on) makes a stop hold until its
399
+ * marker ages out, instead of ending when the entries that tripped it roll out
400
+ * of the window. `0` restores the sum-only behaviour, which unlocks the
401
+ * window on a timer the provider does not keep.
402
+ *
403
+ * @param {Record<string, string | undefined>} [env]
404
+ * @returns {{ windowMs: number, warnAt: number, stopAt: number, sticky: boolean }}
405
+ */
406
+ export function windowLimits(env = process.env) {
407
+ const rawMinutes = Number(env?.OC_HARNESS_BUDGET_WINDOW_MINUTES)
408
+ const minutes =
409
+ Number.isFinite(rawMinutes) && rawMinutes > 0 ? Math.floor(rawMinutes) : DEFAULT_WINDOW_MINUTES
410
+ const warnAt = count(env?.OC_HARNESS_BUDGET_WINDOW_WARN, DEFAULT_WINDOW_WARN_TOKENS)
411
+ const stopAt = count(env?.OC_HARNESS_BUDGET_WINDOW_STOP, DEFAULT_WINDOW_STOP_TOKENS)
412
+ return {
413
+ windowMs: minutes * 60_000,
414
+ warnAt: stopAt > 0 && warnAt > stopAt ? stopAt : warnAt,
415
+ stopAt,
416
+ sticky: env?.OC_HARNESS_BUDGET_WINDOW_STICKY !== "0",
417
+ }
418
+ }
package/src/config.js CHANGED
@@ -29,8 +29,8 @@ import {
29
29
  loadLanes,
30
30
  } from "./profile.js"
31
31
 
32
- import { join } from "node:path"
33
- import { readFileSync } from "node:fs"
32
+ import { join, resolve } from "node:path"
33
+ import { existsSync, readFileSync } from "node:fs"
34
34
  import { homedir } from "node:os"
35
35
  import { BASH_DENYLIST } from "./denylist.js"
36
36
 
@@ -79,6 +79,61 @@ export function kitVersion() {
79
79
  }
80
80
  }
81
81
 
82
+ /** Where opencode's user config lives when nothing overrides it. */
83
+ export const DEFAULT_CONFIG_DIR = join(homedir(), ".config/opencode")
84
+
85
+ /** Where this kit itself lives - the parent of the bundle (or src) directory. */
86
+ export function kitRoot() {
87
+ // `../package.json` from the module's own location is exactly where
88
+ // kitVersion() reads the version from: in dev, `../` of src/; in a bundle,
89
+ // `../` of dist/. Either way it is the kit's root, which is the right
90
+ // second base for a relative skills path that points INTO the kit.
91
+ return new URL("..", import.meta.url).pathname
92
+ }
93
+
94
+ /**
95
+ * The skills.paths entries of a base config, with the dead ones dropped.
96
+ *
97
+ * A `paths` entry that never resolves is not a configuration - it is noise
98
+ * the file carries for every reader to puzzle over, and a reader who DOES
99
+ * puzzle over it gets a wrong model of the world: this kit's config shipped
100
+ * `["../skills"]` which had dl resolved to `~/.config/skills` - a directory
101
+ * that has never existed on this machine - for release after release.
102
+ *
103
+ * A relative entry resolves against the opencode config directory; an
104
+ * absolute one stands as written; a relative one is also checked against the
105
+ * kit root, because an entry pointing INTO the kit was always a plausible
106
+ * intent and the kit knows where its own skills are. Nothing that resolves
107
+ * is kept; word everything else is dropped and named in the header, the same
108
+ * way dropped bash rules and removed lanes are.
109
+ *
110
+ * @param {any} skills
111
+ * @param {string} configDir
112
+ * @param {string} [kitRoot] where the kit itself lives, when overridable
113
+ * @returns {{ skills: any, dropped: string[] }}
114
+ */
115
+ export function cleanSkillPaths(skills, configDir, kitRoot) {
116
+ const paths = skills?.paths
117
+ if (!Array.isArray(paths) || !paths.length) return { skills, dropped: [] }
118
+ const kept = []
119
+ const dropped = []
120
+ for (const p of paths) {
121
+ if (typeof p !== "string" || !p.trim()) {
122
+ dropped.push(String(p))
123
+ continue
124
+ }
125
+ const candidates =
126
+ p.startsWith("/") || p.startsWith("~") ? [p] : [resolve(configDir, p), ...(kitRoot ? [resolve(kitRoot, p)] : [])]
127
+ const live = candidates.some((c) => {
128
+ const real = c.startsWith("~") ? join(homedir(), c.slice(1)) : c
129
+ return existsSync(real)
130
+ })
131
+ if (live) kept.push(p)
132
+ else dropped.push(p)
133
+ }
134
+ return { skills: { ...skills, paths: kept }, dropped }
135
+ }
136
+
82
137
  /**
83
138
  * The memory plugin this kit can turn on.
84
139
  *
@@ -316,7 +371,8 @@ function userBashRules(base) {
316
371
 
317
372
  /**
318
373
  * @param {Profile} p
319
- * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
374
+ * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string>,
375
+ * configDir?: string, kitRoot?: string, skills?: boolean }} [opts]
320
376
  * @returns {string}
321
377
  */
322
378
  export function generateConfig(p, opts = {}) {
@@ -359,6 +415,16 @@ export function generateConfig(p, opts = {}) {
359
415
  ...lane.permission,
360
416
  }
361
417
  }
418
+ // The skill list is the largest fixed cost in the prompt. opencode
419
+ // writes every discovered skill's name and description into the system
420
+ // message, so a machine with a large skill collection pays it on every
421
+ // request, before a word of the conversation - and a long chat pays it
422
+ // hundreds of times. The lanes carry their instructions in prompts/, so
423
+ // `--no-skills` omits the block for them; `skills: true` on a lane keeps
424
+ // it for that lane, and nothing changes when the flag is not passed.
425
+ if (opts.skills === false && lane.skills !== true) {
426
+ existing.tools = { ...(existing.tools ?? {}), skill: false }
427
+ }
362
428
  }
363
429
 
364
430
  // A primary agent may hand work to a lane. The rule is per-agent, and it
@@ -373,6 +439,50 @@ export function generateConfig(p, opts = {}) {
373
439
  agent[role] = existing
374
440
  }
375
441
 
442
+ // A lane the profile no longer manages gets no pin from the loop above -
443
+ // the loop only walks the profile's own roles - so a fleet lane left over
444
+ // from a previous profile survived every later `oc-harness use`, still
445
+ // carrying the previous profile's pinned model and kit prompt, and a model
446
+ // that may no longer match the file's retention header.
447
+ //
448
+ // The entry is removed only when the KIT still owns it: its prompt is the
449
+ // compiler's own lane file. An entry a user has rewritten - a different
450
+ // prompt file, or no prompt at all - is user data, and it is left alone:
451
+ // dropping it here would delete work a `use` cannot recreate.
452
+ //
453
+ // Every removal is named in the header alongside the dropped bash rules.
454
+ const removedLanes = []
455
+ for (const name of Object.keys(agent)) {
456
+ if (roles[name] !== undefined) continue
457
+ if (!lanes[name]) continue
458
+ const prompt = agent[name]?.prompt
459
+ if (typeof prompt === "string" && prompt.endsWith(`/prompts/${name}.md}`)) {
460
+ delete agent[name]
461
+ removedLanes.push(name)
462
+ }
463
+ }
464
+
465
+ // A second bundle hid in the same map: the ECC subagents, whose prompts
466
+ // live at `prompts/agents/<name>.txt`. They are not lanes, so the loop
467
+ // above ignored them, and the live config carried 25 of them - architect,
468
+ // code-reviewer, e2e-runner and the rest. Their descriptions are loaded on
469
+ // every request, which is the 6k-token floor this removes; their prompts
470
+ // are read only when one is delegated to.
471
+ //
472
+ // Ownership is the same rule as above: only an entry whose prompt is the
473
+ // bundle's own `{file:...prompts/agents/<name>.txt}` is removed. An agent
474
+ // the user wrote, or a lane, is untouched.
475
+ const removedBundled = []
476
+ for (const name of Object.keys(agent)) {
477
+ if (roles[name] !== undefined) continue
478
+ if (lanes[name]) continue
479
+ const prompt = agent[name]?.prompt
480
+ if (typeof prompt === "string" && /\{file:[^}]*prompts\/agents\/[^}]+\}/.test(prompt)) {
481
+ delete agent[name]
482
+ removedBundled.push(name)
483
+ }
484
+ }
485
+
376
486
  // The user's own bash rules are merged in, not replaced.
377
487
  //
378
488
  // The map used to be built from the kit's two lists alone, so every rule
@@ -423,6 +533,15 @@ export function generateConfig(p, opts = {}) {
423
533
  delete base_.variant
424
534
  delete base_.tool_output
425
535
 
536
+ // The skills paths are cleaned before the config is built, so the header
537
+ // can name what was dropped: a dead entry is dropped from the file and
538
+ // announced, the same way dropped bash rules and removed lanes are. The
539
+ // kit root comes from the bundle's own location - the same place
540
+ // kitVersion() reads its package.json from - so a relative entry pointing
541
+ // INTO the kit ("skills", "./skills") also stays alive.
542
+ const configDir = opts.configDir ?? DEFAULT_CONFIG_DIR
543
+ const skillPaths = cleanSkillPaths(base.skills ?? {}, configDir, opts.kitRoot ?? kitRoot())
544
+
426
545
  const out = {
427
546
  $schema: "https://opencode.ai/config.json",
428
547
  ...base_,
@@ -480,11 +599,20 @@ export function generateConfig(p, opts = {}) {
480
599
  // numbers if this ever needs arguing again.
481
600
  tool_output: { max_lines: 2000, max_bytes: 51200 },
482
601
 
483
- skills: base.skills ?? {},
602
+ // The skills paths: only the entries that resolve where they would be
603
+ // read from. A dead one is dropped from the file and named in the
604
+ // header - a path to nothing is noise every reader pays for.
605
+ skills: skillPaths.skills ?? {},
484
606
 
485
607
  experimental: {
486
608
  batch_tool: true,
487
- continue_loop_on_deny: true,
609
+ // A denial is a stop, not a prompt. With this on, opencode hands the
610
+ // guard's refusal back to the model and continues the same loop; the
611
+ // measured session made 138 requests and 7,559,016 context tokens in 29
612
+ // minutes on six `shared token window` denials, each retry carrying
613
+ // the whole prompt. Off, the denial ends the turn and the error is what
614
+ // the user sees.
615
+ continue_loop_on_deny: false,
488
616
  ...(base.experimental ?? {}),
489
617
  },
490
618
 
@@ -532,6 +660,10 @@ export function generateConfig(p, opts = {}) {
532
660
  instructionsPath,
533
661
  unhealthy,
534
662
  dropped: own.dropped,
663
+ removedLanes,
664
+ removedBundled,
665
+ skillsDropped: skillPaths.dropped,
666
+ skills: opts.skills,
535
667
  })
536
668
  }
537
669
 
@@ -539,7 +671,8 @@ export function generateConfig(p, opts = {}) {
539
671
  * @param {Record<string, any>} cfg
540
672
  * @param {Profile} p
541
673
  * @param {{ comments: boolean, instructionsPath?: string, unhealthy?: Set<string>,
542
- * dropped?: string[] }} opts
674
+ * dropped?: string[], removedLanes?: string[], removedBundled?: string[],
675
+ * skillsDropped?: string[], skills?: boolean }} opts
543
676
  * @returns {string}
544
677
  */
545
678
  function render(cfg, p, opts) {
@@ -589,6 +722,12 @@ function render(cfg, p, opts) {
589
722
  `${c}output cap ${p.limits.output.toLocaleString("en-US")} tokens`,
590
723
  `${c}reasoning ${levels.length ? levels.join(" < ") : "(not tunable)"}`,
591
724
  `${c}retention ${effective.retention}${effective.retention === "may-train" ? " <-- prompts may train a model" : ""}`,
725
+ ...(opts.skills === false
726
+ ? [
727
+ `${c}skills off (--no-skills): the skill list is not in the prompt`,
728
+ `${c} (every skill's name and description, on every request)`,
729
+ ]
730
+ : []),
592
731
  ...(effective.retention !== p.retention
593
732
  ? [
594
733
  `${c} base ${p.provider}/${p.model} is ${p.retention}, but ${effective.worst} makes the whole file ${effective.retention}`,
@@ -612,6 +751,33 @@ function render(cfg, p, opts) {
612
751
  ...opts.dropped.map((d) => `${c} ${d}`),
613
752
  ]
614
753
  : []),
754
+ ...(opts.removedLanes?.length
755
+ ? [
756
+ `${c}`,
757
+ `${c}Lanes this profile does not manage, removed from the agent map:`,
758
+ ...opts.removedLanes.map((r) => `${c} ${r}`),
759
+ `${c}A lane whose prompt you had rewritten is left alone instead - pin the`,
760
+ `${c}lane back with a prompt of your own if it was yours.`,
761
+ ]
762
+ : []),
763
+ ...(opts.removedBundled?.length
764
+ ? [
765
+ `${c}`,
766
+ `${c}Bundled subagents removed from the agent map (prompt prompts/agents/*):`,
767
+ ...opts.removedBundled.map((r) => `${c} ${r}`),
768
+ `${c}Their descriptions were loaded on every request; hand-written agents`,
769
+ `${c}are left alone.`,
770
+ ]
771
+ : []),
772
+ ...(opts.skillsDropped?.length
773
+ ? [
774
+ `${c}`,
775
+ `${c}Skills paths that do not resolve, dropped from skills.paths:`,
776
+ ...opts.skillsDropped.map((s) => `${c} ${s}`),
777
+ `${c}A path to nothing is noise every reader pays for; the kit's own`,
778
+ `${c}skills arrive as per-file links in the default skills directory.`,
779
+ ]
780
+ : []),
615
781
  `${c}`,
616
782
  `${c}Derived numbers, do not hand-edit:`,
617
783
  `${c} compaction.reserved ${reservedTokens(p, { unhealthy }).toLocaleString("en-US")} (${Math.round((p.effort?.reserveShare ?? 0.12) * 100)}% of the narrowest input cap in play, held back for reasoning growth)`,