@dylanrussell/agent-router 1.1.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # 2.0.1
2
+
3
+ - Normalize V2's `default` variant so pending next-user-turn fallback is not disabled by an equivalent model reference.
4
+ - Pass the native catalog into `router_back` validation instead of launching the obsolete CLI model command.
5
+ - Verify real isolated 2.0.8 HTTP 429/503 failover without replay, explicit model override, stack switching, undo, history and fixture integrity.
6
+ - Patch development dependencies; the full dependency audit reports no known advisories.
7
+
8
+ # 2.0.0
9
+
10
+ - Target OpenCode 2.0.8 with server and terminal `setup` entrypoints.
11
+ - Include literal root `index.ts`/`tui.ts` wrappers for the 2.0.8 local-directory resolver; published packages retain their exports.
12
+ - Add an isolated real-host integration check for package activation, terminal discovery, and RPC input validation through a disposable test probe.
13
+ - Preserve all six router tools, stack operations, history, sidebar and six slash commands.
14
+ - Register terminal commands under a mounted app slot so OpenCode 2.0.8 provides its Keymap context. Sidebar slots return real renderables with reactive child insertion.
15
+ - Register terminal plugins in `cli.json` and use native `plugins` entries, including package/options objects.
16
+ - Validate advertised model variants using the V2 model catalog. Unsupported `max` variants must be changed explicitly to an advertised variant such as `high`.
17
+ - Preserve legacy frontmatter and support capture/apply of native `model#variant` references.
18
+ - Queue fallback selection for the next explicit user prompt after retryable HTTP 429/500/502/503/504. Veto automatic same-turn retry for routed failures; never submit prompts or replay tools.
19
+
20
+ ## Operational limits
21
+
22
+ - Applying/backing out stacks still requires restarting OpenCode to reload agent files.
23
+ - Terminal stack operations access local files; remote-server filesystem management is not supported.
24
+ - Fallback notices are server logs. The sidebar shows configured routing, not session-local fallback state.
25
+ - OpenCode 2.0.8 does not identify request kind on retry hooks. The plugin conservatively tracks the last model-request kind and only handles primary requests. Concurrent auxiliary requests can suppress fallback handling.
26
+ - Session defaults must resolve to explicit agent/model selections before routing. Fallback routing is bounded to 1,024 tracked sessions per plugin instance.
package/README.md CHANGED
@@ -68,6 +68,8 @@ Stacks are config, history is state — point `stacksDir` somewhere dotfile-mana
68
68
 
69
69
  ## Install
70
70
 
71
+ Version 2 targets **OpenCode 2.0.8**. Version 1 remains the legacy OpenCode release. See [release notes and operational limits](./CHANGELOG.md).
72
+
71
73
  ```bash
72
74
  npx -y @dylanrussell/agent-router init
73
75
  ```
@@ -75,13 +77,13 @@ npx -y @dylanrussell/agent-router init
75
77
  `init` will:
76
78
 
77
79
  1. Capture your agents' current models into a first stack (`default`) and mark it active.
78
- 2. Add `@dylanrussell/agent-router@latest` to the `plugin` array in `opencode.json` (backing it up first).
79
- 3. Add the same entry to `tui.json` — this loads the sidebar + `/agent-*` commands (opencode ≥ 1.17).
80
+ 2. Add `@dylanrussell/agent-router@latest` to the `plugins` array in `opencode.json` (backing it up first).
81
+ 3. Add the same entry to `cli.json` — this loads the sidebar + `/agent-*` commands.
80
82
  4. Remove the legacy `@dylanrussell/omo-router` plugin entry if present.
81
83
 
82
84
  Then **restart opencode** so it picks up the plugin.
83
85
 
84
- `@opencode-ai/plugin` is a required peer dependency: the server bundle imports it at runtime. npm installs it on a clean install; do not omit peer dependencies when installing the plugin.
86
+ The API peer is pinned to `@opencode/plugin@2.0.8`. OpenCode provides the terminal renderer at runtime. Both package entrypoints default-export V2 plugin definitions.
85
87
 
86
88
  ## Quickstart
87
89
 
@@ -94,7 +96,7 @@ agent-router current # print the agent → model mapping in fro
94
96
  agent-router status # print active stack name
95
97
  agent-router show cheap # print a stack's JSON
96
98
  agent-router edit cheap # open a stack in $EDITOR
97
- agent-router validate --all # check every stack against `opencode models`
99
+ agent-router validate --all # check model IDs and variants against the V2 catalog
98
100
  agent-router history # list recent switches
99
101
  agent-router import my-mix <file> # import a stack from a file
100
102
  agent-router export my-mix <file> # export a stack to a file
@@ -125,7 +127,7 @@ A stack file needs one thing: an `agents` record whose entries carry a `model` s
125
127
  - Reserved opencode framework keys (`description`, `mode`, `permission`, `tools`, `prompt`, `steps`, `color`, `name`) are never transcribed — a stack entry can't clobber them.
126
128
  - Option values serialize to a single frontmatter line: scalars bare (quoted only when needed), objects/arrays as JSON flow. Block-style values an apply replaces are collapsed to flow; `capture` parses them back into typed JSON.
127
129
 
128
- `use` is strict by design: if a stack references an agent file that doesn't exist, or one without a `model:` line, the switch fails **before anything is written** — your suite is never left half-switched. It also validates every model ID against `opencode models` first (skip with `--no-validate`, override with `--force-invalid`).
130
+ `use` is strict by design: if a stack references an agent file that doesn't exist, or one without a `model:` line, the switch fails **before anything is written**. It validates model IDs and variants against the V2 catalog first (skip with `--no-validate`, override with `--force-invalid`).
129
131
 
130
132
  `capture` is the inverse: it reads the current `model:` line and option keys of every agent file (files without a `model:` line are skipped) and writes a stack. There are no bundled seed stacks — your real setup is the seed.
131
133
 
@@ -154,36 +156,40 @@ Model IDs and variants above are illustrative; use models and variants supported
154
156
  - `fallbacks` is optional, or an array of 0 to 8 objects. Each object contains only `model` (non-whitespace `provider/model`) and optional `variant` (nonempty string). `null`, bare strings, unknown candidate fields, and longer chains are rejected.
155
157
  - Candidate order is preserved. Exact `(model, variant)` duplicates, including the primary, are skipped at runtime. An omitted fallback variant clears the previous candidate's variant.
156
158
  - Only model and variant change at runtime. Agent prompts, permissions, tools, and other provider options remain unchanged; ensure those options are compatible with every candidate.
157
- - Validation checks every primary and fallback ID, reporting paths such as `agents.oracle.fallbacks.0.model`. It checks catalogue membership through `opencode models`, not live provider health, credentials, quota, or variant support. `validate --active` includes applied fallback metadata when its primary still matches frontmatter.
159
+ - Validation checks every primary and fallback ID and variant, reporting paths such as `agents.oracle.fallbacks.0.variant`. Server tools use the connected host catalog; the standalone CLI uses `opencode api model.list`. Catalog membership does not prove live provider health or quota. `validate --active` includes applied fallback metadata when its primary still matches frontmatter.
158
160
  - Failover does not rewrite frontmatter, stacks, history, or router state. `use` stores the applied chains in optional `state.json.fallbackAgents`. Old state files without that field remain valid and have no enabled chains until the stack is reapplied.
159
161
  - `capture` and displaced-history snapshots merge those applied chains with live frontmatter only when primary model and variant still match. Editing a stack alone does not alter applied metadata. Applying a stack without chains clears the applied chains, including agents omitted from that stack.
160
162
  - `back` retains its existing semantics: reapply a previous **named stack as it exists now**, not restore historical snapshot bytes. Runtime failover never adds a history entry.
161
163
 
162
164
  ### Runtime Behavior And Limits
163
165
 
164
- The plugin watches correlated assistant `message.updated` errors, requiring the current user-message parent ID, agent, and selected provider/model. It stages the next candidate only for structured `APIError` with `isRetryable: true` and HTTP **429, 500, 502, 503, or 504**. Native opencode retries may occur before the terminal error reaches this hook. Router does not change their policy or timing.
166
+ The V2 plugin watches `retry` hooks correlated with the admitted user turn, agent and selected model. A retryable HTTP **429, 500, 502, 503, or 504** stages the next candidate and vetoes the same-turn retry. Only primary model requests participate.
165
167
 
166
168
  Authentication/permission errors, context overflow, content filtering, output-length errors, unknown errors, transport errors without an HTTP status, and ambiguous `session.error` notifications do not advance the chain. Router never classifies errors by substring matching.
167
169
 
168
- After a qualifying failure a 15-second warning toast (or log fallback) names the next model and asks for an explicit retry/continue instruction. Every notice is also logged under `agent-router`. **Review partial output and completed tools before continuing.** The mutable `chat.message` hook selects the fallback when the next prompt is admitted; no prompt is submitted by this plugin. The successful fallback stays selected for subsequent turns. Chains advance monotonically with at most eight transitions per agent/session; exhaustion produces a notice, never wraps to the primary. There are no timers, background retries, or automatic failback.
170
+ After a qualifying failure a server log names the next model and asks for an explicit retry/continue instruction. **Review partial output and completed tools before continuing.** The next `prompt` admission hook calls `session.switchModel`; it never submits a prompt. Chains advance monotonically, never wrap, and never replay prompts or tools.
169
171
 
170
- Observed cancellation (`MessageAbortedError`), explicit model/agent switch events, and an incoming model/variant different from both the configured primary and current candidate disable routing for that session until restart. A switch to the same model is distinguishable only when the host emits its switch event. Hosts/UI paths that omit both the event and a changed selection cannot expose that manual intent to this plugin. Switching stacks with router tools disables running failover immediately; external CLI/TUI state changes disable it at the next prompt admission. Restart to activate the newly applied stack.
172
+ Observed interruption, explicit model/agent selection events, and an unexpected incoming model disable routing for that session. Router state changes disable startup routing at the next prompt admission. Restart to activate a newly applied stack.
171
173
 
172
- Duplicate/concurrent error events advance only once per user turn. Selection and bookkeeping run synchronously before notification awaits, so delayed notices cannot overwrite cancellation/manual selection. Sessions and per-agent budgets are isolated; deleted/disabled sessions remain tombstones to prevent rearming. After 1,024 tracked sessions in one plugin instance, additional sessions receive no automatic routing until restart. Untracked sessions and subagents that bypass `chat.message` are deliberately left alone.
174
+ Duplicate failures advance only once per admitted user turn. Routing budgets are isolated by session and agent and bounded to 1,024 sessions. Subagents without user prompt admission are left alone.
173
175
 
174
- **Verified boundary:** inspected opencode **v1.17.15** `packages/opencode/src/session/prompt.ts`: `chat.message` mutates the message before `sessions.updateMessage(info)` and the legacy session loop resolves `lastUser.model`. A deterministic in-process provider harness tests those hook semantics, explicit next-turn continuation, variant selection, and no replay/persistent writes. This is **not a real-server end-to-end provider test** and does not prove that every new v2 execution path invokes these legacy hooks. Hosts that bypass them receive no failover. There is no transparent same-turn continuation guarantee, no automatic recovery of a failed `task` invocation, and no guarantee that an LLM will not choose to repeat a tool when explicitly asked to continue.
176
+ **Verified boundary:** compiled against the published **2.0.8** API types. Deterministic adapter tests cover next-turn selection, retry veto, auxiliary-request exclusion, variants, and no replay or persistent agent writes. These are not real-provider end-to-end tests. An LLM may choose to repeat a tool when explicitly asked to continue.
175
177
 
176
- The installed SDK exposes `/v2` `client.v2.session.switchModel` for subsequent turns, but no atomic compare-and-switch/retry contract was verified. Router deliberately does **not** call it: an asynchronous model write could race a manual selection or cancellation. No direct SDK dependency is needed; plugin hooks are the integration boundary. The sidebar displays configured primary and fallback chains; the TUI model picker and router status display primary assignments. None of these indicate session-local runtime candidates.
178
+ OpenCode 2.0.8 exposes no atomic compare-and-switch in the prompt hook and no request kind in the retry hook. Concurrent manual selections/admissions or auxiliary requests remain host API limitations. The sidebar displays configured routing, not the session-local runtime candidate. Terminal stack operations require local filesystem access; remote server filesystem management is not supported.
177
179
 
178
180
  ### Install This Checkout
179
181
 
180
- Ordered fallbacks require agent-router **1.1.0 or later**. To test a local checkout instead of the published package, run `npm run typecheck`, `npm test`, and `npm run build` using the installed dependencies (a fresh checkout uses `pnpm install --frozen-lockfile`). Replace `/absolute/path/to/agent-router` below with your checkout path, then replace the registry router entry in the **server** `opencode.json` plugin array with:
182
+ To test a local V2 checkout, run `npm run typecheck`, `npm test`, and `npm run build` (a fresh checkout uses `pnpm install --frozen-lockfile`). Replace the registry entry in the server `opencode.json` **plugins** array with the package directory:
181
183
 
182
184
  ```json
183
- "file:///absolute/path/to/agent-router/dist/plugin.js"
185
+ "file:///absolute/path/to/agent-router"
184
186
  ```
185
187
 
186
- Do not load both registry and local server plugins. If using the router TUI plugin, also replace its registry entry in `tui.json` with `file:///absolute/path/to/agent-router/dist/tui.js`. Use the checkout's CLI too: older router builds strictly validate state and cannot read the new `fallbackAgents` field. Add a chain to your stack and run `node /absolute/path/to/agent-router/dist/cli.js use <stack-name>`, then quit and restart opencode. These installation/configuration steps are instructions only; implementation and tests do not modify your live agent configuration. Start a new session, and confirm you see the explicit next-turn notice after a qualifying terminal error. Headless clients must inspect the `agent-router` log.
188
+ Use the same package directory in `cli.json` **plugins** when explicitly registering the terminal half. Do not load both registry and local copies. The local CLI is `node /absolute/path/to/agent-router/dist/cli.js`. Applying a stack still requires restarting OpenCode; inspect server logs for next-turn fallback notices.
189
+
190
+ OpenCode 2.0.8 ignores package exports for local directories. Root `index.ts` and `tui.ts` wrappers load the built files; run `npm run build` before using the source directory.
191
+
192
+ `npm run test:integration` starts an isolated real OpenCode 2.0.8 server, initializes its location, verifies router activation and terminal discovery, and exercises a disposable RPC probe with absent, null, and invalid input. It uses temporary HOME/XDG/router directories and never modifies live configuration. Set `AGENT_ROUTER_TEST_PACKAGE` to an unpacked tarball directory to test release contents. Router itself exposes tools, not RPC methods.
187
193
 
188
194
  ## Inside opencode
189
195
 
@@ -202,7 +208,7 @@ The plugin exposes six tools the agent (or you, by asking it) can call:
202
208
 
203
209
  ## In the TUI
204
210
 
205
- On opencode ≥ 1.17 the plugin also ships a TUI half (loaded from `tui.json`, wired up by `init`):
211
+ The V2 terminal half loads from the package's `./tui` export (`cli.json`, wired up by `init`):
206
212
 
207
213
  - **Sidebar panel** — lists available stacks with the active one checked. Under **Current Stack → Configured routing**, each agent has an indented **Primary** model and ordered **Fallback 1**, **Fallback 2**, … rows. Explicit variants appear in brackets; agents without fallbacks show only their primary. Model IDs are kept in full. A `⟳ restart required` badge appears when the active stack differs from the one at TUI startup. Updates live (≤1.5s), including fallback-only and variant-only edits.
208
214
  - **Configuration, not live failover** — sidebar chains come from the active stack file, not session-local routing or the applied `state.json.fallbackAgents` snapshot. Editing a stack changes this preview but does not apply it: use the stack and restart opencode to activate changes. A fallback row is a configured candidate, not a claim that failover is enabled or that the model is currently running.
@@ -244,7 +250,7 @@ Paths resolve in this order: explicit option → `config.json` → env var → d
244
250
 
245
251
  - **Restart required.** opencode reads agent files once at startup. After every `agent-router use`, restart opencode for the new models to take effect. The CLI reminds you.
246
252
  - **Hand-edits to frontmatter are not auto-saved into stacks.** If you hand-tune a model or option and want to keep it, `capture` it (or `capture <active> --force`). The next `use` that touches that agent overwrites the hand-edit for keys the stack names; keys the stack omits are preserved (set an option to `null` in a stack entry to clear it).
247
- - **Validation is auth-state-dependent.** `agent-router validate` runs `opencode models`, which only lists models reachable through your current auth. If you revoke a key, previously-valid stacks may suddenly be invalid.
253
+ - **Validation is catalog-dependent.** `agent-router validate` uses the active V2 model catalog, including variants. Catalog membership does not guarantee provider health, valid credentials, or available quota.
248
254
 
249
255
  ## FAQ
250
256
 
package/dist/cli.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
4
+ var __esm = (fn, res, err2) => function __init() {
5
+ if (err2) throw err2[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err2 = [e], e;
10
+ }
6
11
  };
7
12
  var __export = (target, all) => {
8
13
  for (var name in all)
@@ -15282,7 +15287,13 @@ var init_schema = __esm({
15282
15287
  stacksDir: external_exports.string().min(1).optional()
15283
15288
  }).strict();
15284
15289
  OpencodeJsonSchema = external_exports.object({
15285
- plugin: external_exports.array(external_exports.string()).optional()
15290
+ plugin: external_exports.array(external_exports.string()).optional(),
15291
+ plugins: external_exports.array(
15292
+ external_exports.union([
15293
+ external_exports.string(),
15294
+ external_exports.object({ package: external_exports.string(), options: external_exports.record(external_exports.string(), external_exports.unknown()).optional() }).passthrough()
15295
+ ])
15296
+ ).optional()
15286
15297
  }).passthrough();
15287
15298
  }
15288
15299
  });
@@ -16020,7 +16031,7 @@ function resolvePaths(options = {}) {
16020
16031
  return {
16021
16032
  opencodeConfigDir,
16022
16033
  opencodeJsonPath: path2.join(opencodeConfigDir, "opencode.json"),
16023
- tuiJsonPath: path2.join(opencodeConfigDir, "tui.json"),
16034
+ tuiJsonPath: path2.join(opencodeConfigDir, "cli.json"),
16024
16035
  agentsDir,
16025
16036
  opencodeBackupsDir: path2.join(opencodeConfigDir, ".backups"),
16026
16037
  routerHome,
@@ -16386,6 +16397,11 @@ var RESERVED_AGENT_KEYS = /* @__PURE__ */ new Set([
16386
16397
  "mode",
16387
16398
  "description",
16388
16399
  "permission",
16400
+ "permissions",
16401
+ "disabled",
16402
+ "disable",
16403
+ "system",
16404
+ "hidden",
16389
16405
  "color",
16390
16406
  "tools",
16391
16407
  "prompt",
@@ -16626,31 +16642,35 @@ async function readPluginConfigFile(filePath, label) {
16626
16642
  return result.data;
16627
16643
  }
16628
16644
  function ensurePluginEntry(config2, entry = PLUGIN_REGISTRY_ENTRY) {
16629
- const existing = config2.plugin ?? [];
16645
+ const existing = config2.plugins ?? config2.plugin ?? [];
16630
16646
  const already = existing.some(
16631
- (p) => p === entry || stripVersionTag(p) === stripVersionTag(entry)
16647
+ (p) => pluginPackage(p) === entry || stripVersionTag(pluginPackage(p)) === stripVersionTag(entry)
16632
16648
  );
16633
16649
  if (already) {
16634
- return { config: config2, result: { added: false, plugin: existing } };
16650
+ return { config: config2, result: { added: false, plugin: existing.map(pluginPackage) } };
16635
16651
  }
16636
16652
  const next = [...existing, entry];
16637
16653
  return {
16638
- config: { ...config2, plugin: next },
16639
- result: { added: true, plugin: next }
16654
+ config: { ...config2, plugins: next },
16655
+ result: { added: true, plugin: next.map(pluginPackage) }
16640
16656
  };
16641
16657
  }
16642
16658
  function removePluginEntry(config2, npmName = LEGACY_PLUGIN_NPM_NAME) {
16643
- const existing = config2.plugin ?? [];
16644
- const removed = existing.filter((p) => stripVersionTag(p) === npmName);
16659
+ const key = config2.plugins ? "plugins" : "plugin";
16660
+ const existing = config2[key] ?? [];
16661
+ const removed = existing.filter((p) => stripVersionTag(pluginPackage(p)) === npmName);
16645
16662
  if (removed.length === 0) {
16646
- return { config: config2, result: { removed: [], plugin: existing } };
16663
+ return { config: config2, result: { removed: [], plugin: existing.map(pluginPackage) } };
16647
16664
  }
16648
- const next = existing.filter((p) => stripVersionTag(p) !== npmName);
16665
+ const next = existing.filter((p) => stripVersionTag(pluginPackage(p)) !== npmName);
16649
16666
  return {
16650
- config: { ...config2, plugin: next },
16651
- result: { removed, plugin: next }
16667
+ config: { ...config2, [key]: next },
16668
+ result: { removed: removed.map(pluginPackage), plugin: next.map(pluginPackage) }
16652
16669
  };
16653
16670
  }
16671
+ function pluginPackage(entry) {
16672
+ return typeof entry === "string" ? entry : entry.package;
16673
+ }
16654
16674
  function stripVersionTag(entry) {
16655
16675
  const lastAt = entry.lastIndexOf("@");
16656
16676
  if (lastAt <= 0) return entry;
@@ -16688,7 +16708,7 @@ import { execFile } from "child_process";
16688
16708
  import { promisify } from "util";
16689
16709
  var execFileAsync = promisify(execFile);
16690
16710
  var DEFAULT_RUNNER = async () => {
16691
- const { stdout } = await execFileAsync("opencode", ["models"], {
16711
+ const { stdout } = await execFileAsync("opencode", ["api", "model.list"], {
16692
16712
  encoding: "utf8",
16693
16713
  timeout: 3e4,
16694
16714
  maxBuffer: 4 * 1024 * 1024
@@ -16697,6 +16717,17 @@ var DEFAULT_RUNNER = async () => {
16697
16717
  };
16698
16718
  function parseModelList(stdout) {
16699
16719
  const out = /* @__PURE__ */ new Set();
16720
+ if (/^[\[{]/.test(stdout.trimStart())) {
16721
+ const parsed = JSON.parse(stdout);
16722
+ const models = Array.isArray(parsed) ? parsed : parsed.data;
16723
+ for (const model of models) {
16724
+ if (model.enabled === false) continue;
16725
+ const id = `${model.providerID}/${model.id}`;
16726
+ out.add(id);
16727
+ for (const variant of model.variants ?? []) out.add(`${id}#${variant.id}`);
16728
+ }
16729
+ return out;
16730
+ }
16700
16731
  for (const raw of stdout.split(/\r?\n/)) {
16701
16732
  const line = raw.trim();
16702
16733
  if (!line) continue;
@@ -16721,6 +16752,19 @@ async function validateStack(stack, options = {}) {
16721
16752
  const available = parseModelList(stdout);
16722
16753
  const refs = collectModelRefs(stack);
16723
16754
  const missing = refs.filter((r) => !available.has(r.modelId));
16755
+ if (/^[\[{]/.test(stdout.trimStart())) {
16756
+ for (const [agent, entry] of Object.entries(stack.agents)) {
16757
+ for (const [index, candidate] of [entry, ...entry.fallbacks ?? []].entries()) {
16758
+ if (!candidate.variant) continue;
16759
+ const modelId = `${candidate.model}#${candidate.variant}`;
16760
+ if (!available.has(modelId))
16761
+ missing.push({
16762
+ path: index === 0 ? `agents.${agent}.variant` : `agents.${agent}.fallbacks.${index - 1}.variant`,
16763
+ modelId
16764
+ });
16765
+ }
16766
+ }
16767
+ }
16724
16768
  return { ok: missing.length === 0, checked: refs.length, missing, available };
16725
16769
  }
16726
16770
  async function validateStackOrThrow(stackName, stack, options = {}) {
@@ -16792,8 +16836,15 @@ async function applyStack(paths, name, options = {}) {
16792
16836
  for (const [agent, entry] of Object.entries(target.agents)) {
16793
16837
  const { filePath, content, model } = await readAgentFileStrict(paths.agentsDir, agent);
16794
16838
  const wantOptions = entryOptions(entry);
16839
+ let wantModel = entry.model;
16840
+ if (model.includes("#") || wantModel.includes("#")) {
16841
+ const [base, inline] = wantModel.split("#");
16842
+ const variant = "variant" in entry ? entry.variant : inline ?? model.split("#")[1];
16843
+ wantModel = `${base}${variant ? `#${variant}` : ""}`;
16844
+ wantOptions.variant = null;
16845
+ }
16795
16846
  const afterOptions = setFrontmatterOptions(content, wantOptions);
16796
- const nextContent = entry.model === model ? afterOptions : setFrontmatterModel(afterOptions, entry.model);
16847
+ const nextContent = wantModel === model ? afterOptions : setFrontmatterModel(afterOptions, wantModel);
16797
16848
  pending.push({ agent, filePath, next: nextContent === content ? null : nextContent });
16798
16849
  }
16799
16850
  const prevState = await readState(paths.statePath);
@@ -16860,6 +16911,10 @@ function entriesToStackAgents(entries) {
16860
16911
  for (const [k, v] of Object.entries(options)) {
16861
16912
  if (k !== "fallbacks") stackEntry[k] = v;
16862
16913
  }
16914
+ if (model.includes("#")) {
16915
+ stackEntry.model = model.slice(0, model.indexOf("#"));
16916
+ stackEntry.variant = model.slice(model.indexOf("#") + 1);
16917
+ }
16863
16918
  out[name] = stackEntry;
16864
16919
  }
16865
16920
  return out;
@@ -16951,7 +17006,7 @@ async function exportStack(paths, name, toFile) {
16951
17006
  }
16952
17007
 
16953
17008
  // src/version.ts
16954
- var VERSION = "1.1.2";
17009
+ var VERSION = "2.0.1";
16955
17010
 
16956
17011
  // src/cli.ts
16957
17012
  var log = (...args) => console.log(...args);
@@ -16995,8 +17050,8 @@ async function cmdInit(paths, opts) {
16995
17050
  }
16996
17051
  if (opts.noEditOpencodeJson) {
16997
17052
  log("agent-router: --no-edit-opencode-json passed; not editing opencode.json or tui.json");
16998
- log(` add this to opencode.json plugin[]: "${PLUGIN_REGISTRY_ENTRY}"`);
16999
- log(` add this to tui.json plugin[] (sidebar): "${PLUGIN_REGISTRY_ENTRY}"`);
17053
+ log(` add this to opencode.json plugins[]: "${PLUGIN_REGISTRY_ENTRY}"`);
17054
+ log(` add this to cli.json plugins[] (sidebar): "${PLUGIN_REGISTRY_ENTRY}"`);
17000
17055
  return;
17001
17056
  }
17002
17057
  await editOpencodeJson(paths);
@@ -17009,7 +17064,7 @@ async function editTuiJson(paths) {
17009
17064
  }
17010
17065
  const result = await ensureTuiJsonPluginEntry(paths.tuiJsonPath);
17011
17066
  if (result.added) {
17012
- log(`agent-router: added "${PLUGIN_REGISTRY_ENTRY}" to tui.json plugin[] (sidebar support)`);
17067
+ log(`agent-router: added "${PLUGIN_REGISTRY_ENTRY}" to cli.json plugins[] (sidebar support)`);
17013
17068
  return;
17014
17069
  }
17015
17070
  log("agent-router: tui.json already up to date");
@@ -17034,7 +17089,7 @@ async function editOpencodeJson(paths) {
17034
17089
  );
17035
17090
  }
17036
17091
  if (ensured.result.added) {
17037
- log(`agent-router: added "${PLUGIN_REGISTRY_ENTRY}" to opencode.json plugin[]`);
17092
+ log(`agent-router: added "${PLUGIN_REGISTRY_ENTRY}" to opencode.json plugins[]`);
17038
17093
  }
17039
17094
  }
17040
17095
  async function cmdList(paths) {