opencode-harness-kit 0.6.4 → 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.
@@ -15,7 +15,7 @@
15
15
  import { chmodSync, copyFileSync, cpSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, symlinkSync, readdirSync, readlinkSync, realpathSync, lstatSync, appendFileSync, renameSync, unlinkSync } from "node:fs"
16
16
  import { homedir } from "node:os"
17
17
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
18
- import { execFileSync } from "node:child_process"
18
+ import { execFileSync, spawnSync } from "node:child_process"
19
19
  import { fileURLToPath } from "node:url"
20
20
  import { createInterface } from "node:readline/promises"
21
21
 
@@ -94,7 +94,17 @@ function ensurePackageCopy() {
94
94
  for (const name of ["dist", "profiles", "prompts", "skills"]) {
95
95
  cpSync(join(ROOT, name), join(COPY_ROOT, name), { recursive: true })
96
96
  }
97
- for (const name of ["lanes.json", "retention.yml", "GLOBAL.md"]) {
97
+ for (const name of ["lanes.json", "retention.yml", "GLOBAL.md", "package.json"]) {
98
+ // `package.json` travels with the copy because `kitVersion()` in
99
+ // src/config.js reads it UP from the plugin bundle's own location
100
+ // (`../package.json` from `dist/`). Without it, every kit field the
101
+ // telemetry plugin and the CLI print from a copy install read
102
+ // "unknown" - an install that could not say which version it was.
103
+ //
104
+ // `bin/` is deliberately NOT copied: nothing inside the copy points at
105
+ // the CLI, and `npx` re-fetches the entry point when the cache has been
106
+ // cleaned. A second copy of a 176KB CLI for a path nobody in the copy
107
+ // walks is dead weight, not durability.
98
108
  cpSync(join(ROOT, name), join(COPY_ROOT, name))
99
109
  }
100
110
  writeFileSync(join(COPY_ROOT, ".source"), `${want}\n`)
@@ -1620,7 +1630,7 @@ async function cmdUse(id, catalog = null, sink = {}) {
1620
1630
  }
1621
1631
  fail(msg, wantJson)
1622
1632
  }
1623
- if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
1633
+ if (!id) fail("usage: oc-harness use <profile> [--print] [--yes] [--no-skills]")
1624
1634
  let p = null
1625
1635
  try {
1626
1636
  p = loadProfile(id)
@@ -1759,7 +1769,15 @@ async function cmdUse(id, catalog = null, sink = {}) {
1759
1769
  )
1760
1770
  }
1761
1771
 
1762
- const out = generateConfig(p, { base: effectiveBase, comments: true })
1772
+ // Relative skills paths resolve against the same directory the file lives
1773
+ // in - the one OpenCode reads them against. The toolkit default assumes
1774
+ // the standard config dir; the CLI knows where it actually wrote this one.
1775
+ const out = generateConfig(p, {
1776
+ base: effectiveBase,
1777
+ comments: true,
1778
+ configDir: CONFIG_DIR,
1779
+ skills: !flag("--no-skills"),
1780
+ })
1763
1781
  if (flag("--print")) {
1764
1782
  process.stdout.write(out)
1765
1783
  return
@@ -3072,8 +3090,8 @@ async function runDoctor(catalog) {
3072
3090
  }
3073
3091
  const live = catalog?.[p.provider]?.models?.[p.model]
3074
3092
  if (!live) {
3075
- console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - may be withdrawn")}`)
3076
- note("drift", id, "not in models.dev - may be withdrawn")
3093
+ console.log(` ${red("✗")} ${id.padEnd(32)} ${red("not in models.dev - drifted from live metadata")}`)
3094
+ note("drift", id, "not in models.dev - drifted from live metadata")
3077
3095
  drift++
3078
3096
  continue
3079
3097
  }
@@ -3525,11 +3543,62 @@ async function cmdRun() {
3525
3543
  fail(`--role ${role} is not one of ${lanes.join(", ")}`)
3526
3544
  }
3527
3545
 
3546
+ // `spawnSync("opencode", ...)` in the driver reports a missing binary as a
3547
+ // failed agent, which marks the FIRST task blocked and blames the task. The
3548
+ // install is the thing that is broken, so say that here, before any task is
3549
+ // touched.
3550
+ try {
3551
+ execFileSync("opencode", ["--version"], { stdio: "ignore", timeout: 15_000 })
3552
+ } catch (err) {
3553
+ const code = err && typeof err === "object" && "code" in err ? err.code : undefined
3554
+ fail(
3555
+ code === "ENOENT"
3556
+ ? "opencode is not on PATH. Install it first: https://opencode.ai/docs"
3557
+ : "opencode --version failed. Fix the install before running the loop.",
3558
+ )
3559
+ }
3560
+
3561
+ // The files: scope check reads `git status`. Outside a work tree it cannot
3562
+ // see anything, so it used to switch itself off in silence -- an unattended
3563
+ // run believing it was fenced while it was not. The one honest place to say
3564
+ // that is here, before the first task, and the explicit override is the same
3565
+ // flag that already means "no scope check".
3566
+ const allowExtraFiles = flag("--allow-extra-files")
3567
+ if (!allowExtraFiles) {
3568
+ const git = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], {
3569
+ encoding: "utf8",
3570
+ timeout: 10_000,
3571
+ })
3572
+ if (git.status !== 0 || String(git.stdout ?? "").trim() !== "true") {
3573
+ fail(
3574
+ "oc-harness run needs a git work tree, because the files: scope check reads git status. Run it inside a repository, or pass --allow-extra-files to run without the check.",
3575
+ )
3576
+ }
3577
+ }
3578
+
3579
+ // A lane's step cap is the other way an agent stops mid-task. When it is
3580
+ // reached opencode exits 0 with a text-only answer, so the loop sees a
3581
+ // failed verify and has no idea why. Reading the cap here lets the stop say
3582
+ // "it ran out of steps" instead of leaving that to the log.
3583
+ let stepCap = 0
3584
+ try {
3585
+ const cfg = existsSync(CONFIG_FILE) ? parseJsonc(readFileSync(CONFIG_FILE, "utf8")) : null
3586
+ const configured = cfg?.agent?.[role]?.steps
3587
+ if (Number.isInteger(configured) && configured > 0) stepCap = configured
3588
+ } catch {
3589
+ // An unreadable config is opencode's failure to report, and it will. A
3590
+ // label on a stop is not worth turning into a second reason to fail.
3591
+ }
3592
+
3528
3593
  const result = runLoop({
3529
3594
  cwd: process.cwd(),
3530
3595
  role,
3531
3596
  maxRounds,
3532
3597
  budgetMinutes,
3598
+ allowExtraFiles,
3599
+ forceUnlock: flag("--force-unlock"),
3600
+ review: flag("--review"),
3601
+ stepCap,
3533
3602
  })
3534
3603
 
3535
3604
  const s = result.summary
@@ -3625,6 +3694,7 @@ try {
3625
3694
  auto refresh + install + use + verify, asking nothing - the
3626
3695
  unattended reconcile, for an agent or a job
3627
3696
  use <id> compile a profile into ${CONFIG_FILE}
3697
+ --no-skills omits the skill list from the lanes' prompt
3628
3698
  show <id> print the resolved lane plan and derived numbers
3629
3699
  refresh <id> update capability fields from models.dev
3630
3700
  install symlink the plugin, prompts, skills and GLOBAL.md into the config dir
@@ -3642,7 +3712,7 @@ try {
3642
3712
  cost what the kit is doing on this project, in numbers
3643
3713
  restore <file> put one back
3644
3714
  baseline measure the current opencode install
3645
- run drive .space/tasks.md to done with the build agent
3715
+ run build and verify .space/tasks.md one task at a time
3646
3716
 
3647
3717
  options
3648
3718
  --print write the config to stdout instead of disk
@@ -3665,6 +3735,14 @@ try {
3665
3735
  --role <agent> which agent does the work (default: build)
3666
3736
  --max-rounds N stop after N tasks (default: 20)
3667
3737
  --budget MIN stop after MIN minutes (default: 120)
3738
+ --allow-extra-files let a task change files its files: line does not
3739
+ name. Without this, an out-of-scope edit stops the loop,
3740
+ and the run needs a git work tree to see the change.
3741
+ --force-unlock clear a lock held by a live pid. Only for a lock left by
3742
+ a pid that was reused; two real runs must not overlap.
3743
+ --review after verify, ask the reviewer lane for a schema'd verdict.
3744
+ The run stops on fail or on any malformed verdict. The
3745
+ verdict is written to .space/review/.
3668
3746
  `)
3669
3747
  // An unknown command is a typo, not a request for help. Without this
3670
3748
  // `oc-harness tsl` prints help and exits 0, so a script calling it never
@@ -0,0 +1,247 @@
1
+ export declare const DEFAULT_WARN_TOKENS = 2000000;
2
+ export declare const DEFAULT_STOP_TOKENS = 5000000;
3
+ export declare const DEFAULT_TURN_STOP_TOKENS = 300000;
4
+ export declare const DEFAULT_WINDOW_MINUTES = 60;
5
+ export declare const DEFAULT_WINDOW_WARN_TOKENS = 4000000;
6
+ export declare const DEFAULT_WINDOW_STOP_TOKENS = 8000000;
7
+ /**
8
+ * What one request cost, in context tokens.
9
+ *
10
+ * A `step-finish` part reports four separate numbers: `input` is the part of
11
+ * the prompt that was not cached, `cache.read` is the cached prefix the model
12
+ * still had to read, `cache.write` is the prefix stored for the next request,
13
+ * and `output`/`reasoning` are what the model wrote. The first three are the
14
+ * context; only the first was counted before, which is why a step that cost
15
+ * 37,324 tokens was recorded as 844.
16
+ *
17
+ * `cache.write` is charged once, on the request that stored it, so summing it
18
+ * here is not double counting the reads that follow. Nor does it overlap
19
+ * `input`: across 605 measured steps the reported `total` was exactly
20
+ * `input + cache.read + cache.write + output + reasoning` every time, so the
21
+ * parts are disjoint and the three that are counted are the whole context.
22
+ *
23
+ * A value that is not a number contributes zero: a missing field must not turn
24
+ * the whole count into NaN, because a NaN comparison never fires a threshold.
25
+ *
26
+ * @param {any} tokens a `step-finish` part's `tokens`
27
+ * @returns {number}
28
+ */
29
+ export declare function requestTokens(tokens: any): number;
30
+ /**
31
+ * The limits this process should use.
32
+ *
33
+ * @param {Record<string, string | undefined>} [env]
34
+ * @returns {{ warnAt: number, stopAt: number, turnStopAt: number }}
35
+ */
36
+ export declare function budgetLimits(env?: Record<string, string | undefined>): {
37
+ warnAt: number;
38
+ stopAt: number;
39
+ turnStopAt: number;
40
+ };
41
+ /**
42
+ * A running total for one session.
43
+ *
44
+ * `add` returns the thresholds this call CROSSED, so the caller can warn once
45
+ * rather than once per request. A request that lands exactly on a threshold
46
+ * crosses it, and a request of zero tokens - a retry that never reached the
47
+ * provider - moves nothing.
48
+ *
49
+ * @param {{ warnAt?: number, stopAt?: number }} [limits]
50
+ */
51
+ export declare function createBudget({ warnAt, stopAt }?: {
52
+ warnAt?: number;
53
+ stopAt?: number;
54
+ }): {
55
+ /**
56
+ * @param {any} input tokens this request cost
57
+ * @returns {{ warn: boolean, stop: boolean }}
58
+ */
59
+ add(input: any): {
60
+ warn: boolean;
61
+ stop: boolean;
62
+ };
63
+ readonly total: number;
64
+ readonly warned: boolean;
65
+ readonly stopped: boolean;
66
+ };
67
+ /**
68
+ * A count with separators, for a message a human reads.
69
+ *
70
+ * @param {number} n
71
+ * @returns {string}
72
+ */
73
+ export declare function tokens(n: number): string;
74
+ /**
75
+ * Where the shared ledger lives.
76
+ *
77
+ * `XDG_STATE_HOME` is the right place for state that survives a reboot and is
78
+ * not configuration; falling back to `~/.local/state` is what the spec says to
79
+ * do. `OC_HARNESS_BUDGET_FILE` overrides both, which is what the tests use and
80
+ * what a user on a shared account would want.
81
+ *
82
+ * @param {Record<string, string | undefined>} [env]
83
+ * @returns {string}
84
+ */
85
+ export declare function ledgerPath(env?: Record<string, string | undefined>): string;
86
+ /**
87
+ * The entries in the ledger, or none.
88
+ *
89
+ * Three answers, not two, because "I could not read the count" and "the count
90
+ * is zero" are different facts and only one of them means there is room left:
91
+ *
92
+ * - no file -> `[]`: nothing has been spent yet.
93
+ * - a file that parses -> its entries.
94
+ * - a file that does not parse -> `null`.
95
+ *
96
+ * It used to return `[]` for the third case, which made a corrupt or
97
+ * half-written ledger indistinguishable from an empty window: the shared stop
98
+ * silently disappeared. `sharedWindowSpent()` in `guard.ts` repairs the file
99
+ * instead (see `repairSpend`): a ledger that cannot be read must not lock the
100
+ * window forever, because the only way out is a hand-delete the user may not
101
+ * know about. Nothing here throws.
102
+ *
103
+ * A `stop` entry is a marker, not a spend: `{ t, n: 0, stop: true }` records
104
+ * that the window was locked at `t`, so the lock can outlive the sliding sum
105
+ * that tripped it.
106
+ *
107
+ * @param {string} path
108
+ * @returns {{ t: number, n: number, stop?: boolean }[] | null}
109
+ */
110
+ export declare function readSpend(path: string): {
111
+ t: number;
112
+ n: number;
113
+ stop?: boolean;
114
+ }[] | null;
115
+ /**
116
+ * Keep the entries that are still inside the keep window, oldest first.
117
+ *
118
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
119
+ * @param {number} now
120
+ * @returns {{ t: number, n: number, stop?: boolean }[]}
121
+ */
122
+ export declare function pruneSpend(entries: {
123
+ t: number;
124
+ n: number;
125
+ stop?: boolean;
126
+ }[], now: number): {
127
+ t: number;
128
+ n: number;
129
+ stop?: boolean;
130
+ }[];
131
+ /**
132
+ * Move an unreadable ledger aside and start a fresh one.
133
+ *
134
+ * The old code read a corrupt file as "spent" and failed closed, which is the
135
+ * right answer for a single tool call and the wrong answer for the machine:
136
+ * the only exit was deleting a file the user had never heard of, so the next
137
+ * session was blocked for six hours by a truncated JSON write. The evidence
138
+ * still matters - a corrupt ledger is a bug worth reading - so the broken file
139
+ * is renamed to `<path>.corrupt` rather than deleted, and an empty ledger
140
+ * replaces it. The count cannot be recovered, so the new file starts at zero;
141
+ * if the old file had a stop marker, `.corrupt` still holds it.
142
+ *
143
+ * Returns false when the repair could not be written; the caller then falls
144
+ * back to the old fail-closed answer rather than pretending the window is
145
+ * empty.
146
+ *
147
+ * @param {string} path
148
+ * @returns {boolean}
149
+ */
150
+ export declare function repairSpend(path: string): boolean;
151
+ /**
152
+ * Write the ledger, atomically.
153
+ *
154
+ * Read-modify-write without a lock: two sessions finishing a step in the same
155
+ * millisecond can lose one entry. That is a few thousand tokens on a budget of
156
+ * millions, and a lock file in this path is a worse failure mode - a crashed
157
+ * session would leave the window blocked for everyone.
158
+ *
159
+ * @param {string} path
160
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
161
+ */
162
+ export declare function writeSpend(path: string, entries: {
163
+ t: number;
164
+ n: number;
165
+ stop?: boolean;
166
+ }[]): void;
167
+ /**
168
+ * Add one request's cost to the shared ledger.
169
+ *
170
+ * A ledger that cannot be read is repaired first, so the first write after a
171
+ * corruption appends to a fresh file instead of overwriting the broken one.
172
+ * The repair does not invent a stop: the count is unknown, and a marker that
173
+ * holds for six hours is the failure this repair exists to end.
174
+ *
175
+ * @param {string} path
176
+ * @param {number} n
177
+ * @param {number} [now]
178
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
179
+ */
180
+ export declare function recordSpend(path: string, n: number, now?: number): {
181
+ t: number;
182
+ n: number;
183
+ stop?: boolean;
184
+ }[];
185
+ /**
186
+ * Record that the window was locked, so the lock survives the window it was
187
+ * measured in.
188
+ *
189
+ * The sum is over the last hour, and the provider's window is not: it stayed
190
+ * shut for hours after the stop fired. A lock that releases itself on a timer
191
+ * is a lock that was only partly on, so the marker holds until `KEEP_MS`.
192
+ *
193
+ * @param {string} path
194
+ * @param {number} [now]
195
+ * @returns {{ t: number, n: number, stop?: boolean }[]} the pruned ledger
196
+ */
197
+ export declare function recordStop(path: string, now?: number): {
198
+ t: number;
199
+ n: number;
200
+ stop?: boolean;
201
+ }[];
202
+ /**
203
+ * Whether a stop marker is still inside the keep window.
204
+ *
205
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
206
+ * @param {number} now
207
+ * @returns {boolean}
208
+ */
209
+ export declare function stopRecorded(entries: {
210
+ t: number;
211
+ n: number;
212
+ stop?: boolean;
213
+ }[], now: number): boolean;
214
+ /**
215
+ * Tokens spent in the last `windowMs`, across every session on this machine.
216
+ *
217
+ * @param {{ t: number, n: number, stop?: boolean }[]} entries
218
+ * @param {number} now
219
+ * @param {number} windowMs
220
+ * @returns {number}
221
+ */
222
+ export declare function windowSpend(entries: {
223
+ t: number;
224
+ n: number;
225
+ stop?: boolean;
226
+ }[], now: number, windowMs: number): number;
227
+ /**
228
+ * The shared-window thresholds.
229
+ *
230
+ * `OC_HARNESS_BUDGET_WINDOW_MINUTES` sets the window (default 60). Zero or a
231
+ * value that is not a positive number falls back to the default: a window of
232
+ * zero would sum nothing and silently disable the tripwire.
233
+ *
234
+ * `OC_HARNESS_BUDGET_WINDOW_STICKY` (default on) makes a stop hold until its
235
+ * marker ages out, instead of ending when the entries that tripped it roll out
236
+ * of the window. `0` restores the sum-only behaviour, which unlocks the
237
+ * window on a timer the provider does not keep.
238
+ *
239
+ * @param {Record<string, string | undefined>} [env]
240
+ * @returns {{ windowMs: number, warnAt: number, stopAt: number, sticky: boolean }}
241
+ */
242
+ export declare function windowLimits(env?: Record<string, string | undefined>): {
243
+ windowMs: number;
244
+ warnAt: number;
245
+ stopAt: number;
246
+ sticky: boolean;
247
+ };
package/dist/config.d.ts CHANGED
@@ -27,6 +27,35 @@ export declare const GLOBAL_INSTRUCTIONS = "~/.config/opencode/GLOBAL.md";
27
27
  * @returns {string}
28
28
  */
29
29
  export declare function kitVersion(): string;
30
+ /** Where opencode's user config lives when nothing overrides it. */
31
+ export declare const DEFAULT_CONFIG_DIR: string;
32
+ /** Where this kit itself lives - the parent of the bundle (or src) directory. */
33
+ export declare function kitRoot(): string;
34
+ /**
35
+ * The skills.paths entries of a base config, with the dead ones dropped.
36
+ *
37
+ * A `paths` entry that never resolves is not a configuration - it is noise
38
+ * the file carries for every reader to puzzle over, and a reader who DOES
39
+ * puzzle over it gets a wrong model of the world: this kit's config shipped
40
+ * `["../skills"]` which had dl resolved to `~/.config/skills` - a directory
41
+ * that has never existed on this machine - for release after release.
42
+ *
43
+ * A relative entry resolves against the opencode config directory; an
44
+ * absolute one stands as written; a relative one is also checked against the
45
+ * kit root, because an entry pointing INTO the kit was always a plausible
46
+ * intent and the kit knows where its own skills are. Nothing that resolves
47
+ * is kept; word everything else is dropped and named in the header, the same
48
+ * way dropped bash rules and removed lanes are.
49
+ *
50
+ * @param {any} skills
51
+ * @param {string} configDir
52
+ * @param {string} [kitRoot] where the kit itself lives, when overridable
53
+ * @returns {{ skills: any, dropped: string[] }}
54
+ */
55
+ export declare function cleanSkillPaths(skills: any, configDir: string, kitRoot?: string): {
56
+ skills: any;
57
+ dropped: string[];
58
+ };
30
59
  /**
31
60
  * The memory plugin this kit can turn on.
32
61
  *
@@ -122,11 +151,15 @@ export declare const BASH_ALLOWLIST: string[];
122
151
  export { BASH_DENYLIST } from "./denylist.js";
123
152
  /**
124
153
  * @param {Profile} p
125
- * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string> }} [opts]
154
+ * @param {{ base?: Record<string, any>, comments?: boolean, unhealthy?: Set<string>,
155
+ * configDir?: string, kitRoot?: string, skills?: boolean }} [opts]
126
156
  * @returns {string}
127
157
  */
128
158
  export declare function generateConfig(p: Profile, opts?: {
129
159
  base?: Record<string, any>;
130
160
  comments?: boolean;
131
161
  unhealthy?: Set<string>;
162
+ configDir?: string;
163
+ kitRoot?: string;
164
+ skills?: boolean;
132
165
  }): string;
package/dist/effort.d.ts CHANGED
@@ -35,12 +35,23 @@ export type EffortResult = {
35
35
  */
36
36
  export declare const MANAGED_ROLES: string[];
37
37
  /**
38
+ * The spent routes the effort plan honours.
39
+ *
40
+ * `opts.unhealthy` is a bare-model set a caller computed already - the one
41
+ * contract the tests and the CLI use. `opts.dir` is the project root, and it
42
+ * asks the plan to read the health file itself under the profile's OWN
43
+ * provider: a health file keyed `openrouter/<x>` must not read as a spent
44
+ * `opencode/<x>`, and a bare set has already lost the provider it came from,
45
+ * so the caller cannot narrow it after the fact. Passing both is the
46
+ * caller's narrowed set, and it wins.
47
+ *
38
48
  * @param {string} profileId
39
- * @param {{ unhealthy?: Set<string> }} [opts]
49
+ * @param {{ unhealthy?: Set<string>, dir?: string }} [opts]
40
50
  * @returns {EffortResult}
41
51
  */
42
52
  export declare function planEffort(profileId: string, opts?: {
43
53
  unhealthy?: Set<string>;
54
+ dir?: string;
44
55
  }): EffortResult;
45
56
  /**
46
57
  * Mutate the resolved OpenCode config so each managed agent declares its own
package/dist/guard.d.ts CHANGED
@@ -8,6 +8,11 @@ export declare const MARKER = "[opencode-harness-kit] blocked";
8
8
  * markers, and a plain unified diff with `--- a/path` and `+++ b/path`
9
9
  * headers.
10
10
  *
11
+ * An update may also carry `*** Move to: path`, which is the path the file
12
+ * is written TO. Reading only the `*** Update File:` line checked the file
13
+ * the patch read from and left the destination unchecked, so a patch could
14
+ * move a file out of the project while every path it named was inside it.
15
+ *
11
16
  * A unified diff names the file TWICE: `--- a/path` is where the change
12
17
  * comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
13
18
  * and `+++ /dev/null`, so the path that has to be scope-checked is the one
package/dist/health.d.ts CHANGED
@@ -110,11 +110,22 @@ export declare function unhealthyRoutes(dir: string): Record<string, SpentEntry>
110
110
  * lane chains list bare model ids, so the prefix is dropped here. Comparing
111
111
  * the two directly would never match, and a spent route would look healthy.
112
112
  *
113
+ * The drop collides when two providers host the same model id: a recorded
114
+ * `openrouter/<x>` then reads as a spent `opencode/<x>`, and every lane whose
115
+ * chain names the opencode one is pushed off a route that is fine. `provider`
116
+ * narrows the answer to the routes a session of THAT provider can actually
117
+ * hit - a provider-qualified key for another provider is dropped, and a key
118
+ * with no provider at all is kept, because a bare key is a standing statement
119
+ * about the model id and it is not readable as any narrower one. Callers
120
+ * including the router, whose lane set may not share one provider, keep the
121
+ * unfiltered call and its old behaviour.
122
+ *
113
123
  * A route whose window has passed is not spent any more, and is not returned
114
124
  * here: the provider's quota comes back at 00:00 UTC, and a lane that stays
115
125
  * on its fallback forever is a lane that never comes back either.
116
126
  *
117
127
  * @param {string} dir the project root
128
+ * @param {string} [provider] only count routes served by this provider
118
129
  * @returns {Set<string>}
119
130
  */
120
- export declare function unhealthyModels(dir: string): Set<string>;
131
+ export declare function unhealthyModels(dir: string, provider?: string): Set<string>;