@dylanrussell/agent-router 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/plugin.js CHANGED
@@ -5,6 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/plugin.ts
8
+ import { readFileSync } from "fs";
8
9
  import { tool } from "@opencode-ai/plugin";
9
10
 
10
11
  // src/core/config.ts
@@ -14618,14 +14619,26 @@ function date4(params) {
14618
14619
  config(en_default());
14619
14620
 
14620
14621
  // src/core/schema.ts
14622
+ var FallbackSchema = external_exports.object({
14623
+ model: external_exports.string().regex(/^[^/\s]+\/\S+$/, "Expected provider/model"),
14624
+ variant: external_exports.string().min(1).optional()
14625
+ }).strict();
14626
+ var RoutingEntrySchema = external_exports.object({
14627
+ model: external_exports.string().min(1),
14628
+ variant: external_exports.string().min(1).nullable().optional(),
14629
+ fallbacks: external_exports.array(FallbackSchema).max(8).optional()
14630
+ }).strict();
14621
14631
  var StateFileSchema = external_exports.object({
14622
14632
  version: external_exports.literal(1),
14623
14633
  active: external_exports.string().min(1),
14624
14634
  previousActive: external_exports.string().min(1).nullable(),
14625
- lastSwitchedAt: external_exports.string().min(1)
14635
+ lastSwitchedAt: external_exports.string().min(1),
14636
+ fallbackAgents: external_exports.record(external_exports.string(), RoutingEntrySchema).optional()
14626
14637
  }).strict();
14627
14638
  var AgentEntrySchema = external_exports.object({
14628
- model: external_exports.string().min(1)
14639
+ model: external_exports.string().min(1),
14640
+ variant: external_exports.string().min(1).nullable().optional(),
14641
+ fallbacks: external_exports.array(FallbackSchema).max(8).optional()
14629
14642
  }).passthrough();
14630
14643
  var StackFileSchema = external_exports.object({
14631
14644
  agents: external_exports.record(external_exports.string(), AgentEntrySchema)
@@ -14701,12 +14714,455 @@ async function resolvePathsWithConfig(options = {}) {
14701
14714
  return resolvePaths(next);
14702
14715
  }
14703
14716
 
14717
+ // src/core/failover.ts
14718
+ var Model = external_exports.object({
14719
+ providerID: external_exports.string(),
14720
+ modelID: external_exports.string(),
14721
+ variant: external_exports.string().optional()
14722
+ });
14723
+ var ErrorInfo = external_exports.object({
14724
+ name: external_exports.string(),
14725
+ data: external_exports.object({ statusCode: external_exports.number().optional(), isRetryable: external_exports.boolean().optional() }).passthrough()
14726
+ });
14727
+ var Event = external_exports.object({
14728
+ type: external_exports.string(),
14729
+ properties: external_exports.object({
14730
+ sessionID: external_exports.string().optional(),
14731
+ error: ErrorInfo.optional(),
14732
+ info: external_exports.object({
14733
+ id: external_exports.string().optional(),
14734
+ sessionID: external_exports.string().optional(),
14735
+ role: external_exports.string().optional(),
14736
+ parentID: external_exports.string().optional(),
14737
+ agent: external_exports.string().optional(),
14738
+ providerID: external_exports.string().optional(),
14739
+ modelID: external_exports.string().optional(),
14740
+ variant: external_exports.string().optional(),
14741
+ error: ErrorInfo.optional()
14742
+ }).passthrough().optional()
14743
+ }).passthrough()
14744
+ });
14745
+ function selection(entry) {
14746
+ const slash = entry.model.indexOf("/");
14747
+ return {
14748
+ providerID: entry.model.slice(0, slash),
14749
+ modelID: entry.model.slice(slash + 1),
14750
+ variant: entry.variant ?? void 0
14751
+ };
14752
+ }
14753
+ function same(a, b) {
14754
+ return a.providerID === b.providerID && a.modelID === b.modelID && a.variant === b.variant;
14755
+ }
14756
+ function createFailover(routes, notice) {
14757
+ const sessions = /* @__PURE__ */ new Map();
14758
+ let enabled = true;
14759
+ const chains = new Map(
14760
+ Object.entries(routes).map(([agent, entry]) => {
14761
+ const chain = [entry, ...entry.fallbacks ?? []].map(selection);
14762
+ return [
14763
+ agent,
14764
+ chain.filter((value, i) => !chain.slice(0, i).some((previous) => same(previous, value)))
14765
+ ];
14766
+ })
14767
+ );
14768
+ const notify = (message) => notice(`agent-router: ${message}`).catch(() => {
14769
+ });
14770
+ return {
14771
+ disable() {
14772
+ enabled = false;
14773
+ sessions.clear();
14774
+ },
14775
+ async message(input, output) {
14776
+ if (!enabled) return;
14777
+ const message = output.message;
14778
+ const chain = chains.get(message.agent);
14779
+ if (!chain || chain.length < 2) {
14780
+ const existing = sessions.get(input.sessionID);
14781
+ if (existing) {
14782
+ existing.turn = void 0;
14783
+ existing.pending = void 0;
14784
+ }
14785
+ return;
14786
+ }
14787
+ let state = sessions.get(input.sessionID);
14788
+ if (!state) {
14789
+ if (sessions.size >= 1024) return;
14790
+ state = { disabled: false, indices: /* @__PURE__ */ new Map() };
14791
+ sessions.set(input.sessionID, state);
14792
+ }
14793
+ if (state.disabled) return;
14794
+ const current = chain[state.indices.get(message.agent) ?? 0];
14795
+ const primary = chain[0];
14796
+ if (!current || !primary) return;
14797
+ const incoming = message.model;
14798
+ const previous = state.turn?.agent === message.agent ? state.turn : void 0;
14799
+ const pending = previous ? state.pending : void 0;
14800
+ if (!same(incoming, current) && !same(incoming, primary) && !(pending && previous && same(incoming, previous.model))) {
14801
+ state.disabled = true;
14802
+ state.pending = void 0;
14803
+ return;
14804
+ }
14805
+ state.pending = void 0;
14806
+ if (pending) message.model = { ...pending };
14807
+ else message.model = { ...current };
14808
+ state.turn = {
14809
+ agent: message.agent,
14810
+ parent: message.id,
14811
+ model: { ...message.model },
14812
+ handled: false
14813
+ };
14814
+ if (pending)
14815
+ await notify(
14816
+ `[${input.sessionID}] selected ${pending.providerID}/${pending.modelID} for this new turn. No previous prompt or tool was replayed.`
14817
+ );
14818
+ },
14819
+ async event(input) {
14820
+ if (!enabled) return;
14821
+ const parsed = Event.safeParse(input.event);
14822
+ if (!parsed.success) return;
14823
+ const { type, properties: p } = parsed.data;
14824
+ const sessionID = p.sessionID ?? p.info?.sessionID ?? (type === "session.deleted" ? p.info?.id : void 0);
14825
+ if (!sessionID) return;
14826
+ const state = sessions.get(sessionID);
14827
+ if (!state || state.disabled) return;
14828
+ if (type === "session.deleted" || type === "session.next.model.switched" || type === "session.next.agent.switched" || type === "session.error" && p.error?.name === "MessageAbortedError") {
14829
+ state.disabled = true;
14830
+ state.pending = void 0;
14831
+ return;
14832
+ }
14833
+ if (type !== "message.updated") return;
14834
+ const info = p.info;
14835
+ const turn = state.turn;
14836
+ if (!info || !turn || info.role !== "assistant" || info.parentID !== turn.parent || info.agent !== turn.agent || info.providerID !== turn.model.providerID || info.modelID !== turn.model.modelID || !info.error)
14837
+ return;
14838
+ if (info.error.name === "MessageAbortedError") {
14839
+ state.disabled = true;
14840
+ state.pending = void 0;
14841
+ return;
14842
+ }
14843
+ if (turn.handled) return;
14844
+ turn.handled = true;
14845
+ const error51 = info.error;
14846
+ const status = error51.data.statusCode;
14847
+ if (error51.name !== "APIError" || !error51.data.isRetryable || status === void 0 || ![429, 500, 502, 503, 504].includes(status))
14848
+ return;
14849
+ const chain = chains.get(turn.agent);
14850
+ if (!chain) return;
14851
+ const index = (state.indices.get(turn.agent) ?? 0) + 1;
14852
+ const next = chain[index];
14853
+ if (!next) {
14854
+ await notify(
14855
+ `[${sessionID}] fallback chain exhausted for ${turn.agent}. Choose a model manually; no retry was sent.`
14856
+ );
14857
+ return;
14858
+ }
14859
+ state.indices.set(turn.agent, index);
14860
+ state.pending = next;
14861
+ await notify(
14862
+ `[${sessionID}] ${turn.agent} failed (HTTP ${status}). Next user turn will use ${next.providerID}/${next.modelID}${next.variant ? ` (${next.variant})` : ""}. Send an explicit retry/continue instruction after reviewing partial output and completed tools. Nothing was retried automatically.`
14863
+ );
14864
+ }
14865
+ };
14866
+ }
14867
+
14704
14868
  // src/core/frontmatter.ts
14705
14869
  import { existsSync as existsSync2 } from "fs";
14706
14870
  import { readFile as readFile2, readdir } from "fs/promises";
14707
14871
  import path3 from "path";
14872
+
14873
+ // src/core/yaml-lite.ts
14874
+ var RESERVED_WORDS = /* @__PURE__ */ new Set([
14875
+ "null",
14876
+ "true",
14877
+ "false",
14878
+ "yes",
14879
+ "no",
14880
+ "on",
14881
+ "off",
14882
+ "~",
14883
+ // YAML 1.1 nulls
14884
+ "Null",
14885
+ "NULL",
14886
+ "True",
14887
+ "False",
14888
+ "Yes",
14889
+ "No",
14890
+ "On",
14891
+ "Off"
14892
+ ]);
14893
+ var BARE_STRING_RE = /^[A-Za-z0-9._\-\/+@$%^&()~]+$/;
14894
+ function needsQuoting(s) {
14895
+ if (s === "") return true;
14896
+ if (RESERVED_WORDS.has(s)) return true;
14897
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return true;
14898
+ if (/^0x[0-9A-Fa-f]+$/.test(s)) return true;
14899
+ if (/^[+-]?\.(inf|Inf|INF|nan|NaN|NAN)$/.test(s)) return true;
14900
+ if (!BARE_STRING_RE.test(s)) return true;
14901
+ return false;
14902
+ }
14903
+ function serializeOptionValue(value) {
14904
+ if (value === null || value === void 0) return "null";
14905
+ if (typeof value === "boolean") return value ? "true" : "false";
14906
+ if (typeof value === "number") {
14907
+ if (!Number.isFinite(value)) return String(value);
14908
+ return JSON.stringify(value);
14909
+ }
14910
+ if (typeof value === "bigint") return String(value);
14911
+ if (typeof value === "string") return needsQuoting(value) ? JSON.stringify(value) : value;
14912
+ return JSON.stringify(value);
14913
+ }
14914
+ function stripComment(line) {
14915
+ let inSingle = false;
14916
+ let inDouble = false;
14917
+ for (let i = 0; i < line.length; i++) {
14918
+ const ch = line[i];
14919
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
14920
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
14921
+ else if (ch === "#" && !inSingle && !inDouble) {
14922
+ if (i === 0 || /\s/.test(line[i - 1] ?? "")) return line.slice(0, i);
14923
+ }
14924
+ }
14925
+ return line;
14926
+ }
14927
+ function tokenize(block) {
14928
+ const out = [];
14929
+ for (const raw of block.split(/\r?\n/)) {
14930
+ if (raw.trim() === "") continue;
14931
+ const indentMatch = /^( *)/.exec(raw);
14932
+ const indent = indentMatch?.[1]?.length ?? 0;
14933
+ const content = stripComment(raw.slice(indent));
14934
+ if (content.trim() === "") continue;
14935
+ out.push({ indent, text: content.trimEnd(), raw });
14936
+ }
14937
+ return out;
14938
+ }
14939
+ function coerceScalar(raw) {
14940
+ const v = raw.trim();
14941
+ if (v === "" || v === "null" || v === "~" || v === "Null" || v === "NULL") return null;
14942
+ if (v === "true" || v === "True" || v === "TRUE") return true;
14943
+ if (v === "false" || v === "False" || v === "FALSE") return false;
14944
+ if (v === "yes" || v === "Yes" || v === "YES") return true;
14945
+ if (v === "no" || v === "No" || v === "NO") return false;
14946
+ if (v === "on" || v === "On" || v === "ON") return true;
14947
+ if (v === "off" || v === "Off" || v === "OFF") return false;
14948
+ if (/^[+-]?\d+$/.test(v)) return Number.parseInt(v, 10);
14949
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(v)) return Number.parseFloat(v);
14950
+ if (/^0x[0-9A-Fa-f]+$/.test(v)) return Number.parseInt(v, 16);
14951
+ return v;
14952
+ }
14953
+ function parseQuoted(s) {
14954
+ const quote = s[0] ?? "";
14955
+ if (quote !== '"' && quote !== "'") return null;
14956
+ if (quote === '"') {
14957
+ let out2 = "";
14958
+ for (let i = 1; i < s.length; i++) {
14959
+ const ch = s[i] ?? "";
14960
+ if (ch === "\\" && i + 1 < s.length) {
14961
+ const next = s[i + 1] ?? "";
14962
+ const map2 = {
14963
+ n: "\n",
14964
+ t: " ",
14965
+ r: "\r",
14966
+ '"': '"',
14967
+ "\\": "\\",
14968
+ "/": "/",
14969
+ b: "\b",
14970
+ f: "\f"
14971
+ };
14972
+ out2 += map2[next] ?? next;
14973
+ i++;
14974
+ } else if (ch === '"') {
14975
+ return { value: out2, rest: s.slice(i + 1) };
14976
+ } else {
14977
+ out2 += ch;
14978
+ }
14979
+ }
14980
+ return null;
14981
+ }
14982
+ let out = "";
14983
+ for (let i = 1; i < s.length; i++) {
14984
+ const ch = s[i] ?? "";
14985
+ if (ch === "'") {
14986
+ if (s[i + 1] === "'") {
14987
+ out += "'";
14988
+ i++;
14989
+ } else {
14990
+ return { value: out, rest: s.slice(i + 1) };
14991
+ }
14992
+ } else {
14993
+ out += ch;
14994
+ }
14995
+ }
14996
+ return null;
14997
+ }
14998
+ function splitFlow(raw) {
14999
+ const parts = [];
15000
+ let depth = 0;
15001
+ let inSingle = false;
15002
+ let inDouble = false;
15003
+ let start = 0;
15004
+ for (let i = 0; i < raw.length; i++) {
15005
+ const ch = raw[i];
15006
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
15007
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
15008
+ else if (!inSingle && !inDouble) {
15009
+ if (ch === "{" || ch === "[") depth++;
15010
+ else if (ch === "}" || ch === "]") depth--;
15011
+ else if (ch === "," && depth === 0) {
15012
+ parts.push(raw.slice(start, i));
15013
+ start = i + 1;
15014
+ }
15015
+ }
15016
+ }
15017
+ parts.push(raw.slice(start));
15018
+ return parts;
15019
+ }
15020
+ function parseFlowValue(raw) {
15021
+ const s = raw.trim();
15022
+ if (s === "") return null;
15023
+ const first = s[0] ?? "";
15024
+ if (first === "{") {
15025
+ const inner = s.slice(1, s.length - 1);
15026
+ const obj = {};
15027
+ for (const part of splitFlow(inner)) {
15028
+ const t = part.trim();
15029
+ if (t === "") continue;
15030
+ const colon = findColon(t);
15031
+ if (colon < 0) {
15032
+ obj[t] = null;
15033
+ } else {
15034
+ const k = t.slice(0, colon).trim();
15035
+ const v = t.slice(colon + 1).trim();
15036
+ obj[stripKeyQuotes(k)] = parseFlowValue(v);
15037
+ }
15038
+ }
15039
+ return obj;
15040
+ }
15041
+ if (first === "[") {
15042
+ const inner = s.slice(1, s.length - 1);
15043
+ return splitFlow(inner).filter((p) => p.trim() !== "").map((p) => parseFlowValue(p));
15044
+ }
15045
+ if (first === '"' || first === "'") {
15046
+ const q = parseQuoted(s);
15047
+ return q ? q.value : s;
15048
+ }
15049
+ return coerceScalar(s);
15050
+ }
15051
+ function findColon(s) {
15052
+ let inSingle = false;
15053
+ let inDouble = false;
15054
+ let depth = 0;
15055
+ for (let i = 0; i < s.length; i++) {
15056
+ const ch = s[i];
15057
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
15058
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
15059
+ else if (!inSingle && !inDouble) {
15060
+ if (ch === "{" || ch === "[") depth++;
15061
+ else if (ch === "}" || ch === "]") depth--;
15062
+ else if (ch === ":" && depth === 0) return i;
15063
+ }
15064
+ }
15065
+ return -1;
15066
+ }
15067
+ function stripKeyQuotes(k) {
15068
+ if (k.length >= 2 && (k[0] === '"' && k.at(-1) === '"' || k[0] === "'" && k.at(-1) === "'")) {
15069
+ return k.slice(1, -1);
15070
+ }
15071
+ return k;
15072
+ }
15073
+ function parseMapping(lines, startIdx, indent) {
15074
+ const out = {};
15075
+ let i = startIdx;
15076
+ while (i < lines.length) {
15077
+ const line = lines[i];
15078
+ if (!line) break;
15079
+ if (line.indent < indent) break;
15080
+ if (line.indent > indent) {
15081
+ i++;
15082
+ continue;
15083
+ }
15084
+ const colon = findColon(line.text);
15085
+ if (colon < 0) {
15086
+ i++;
15087
+ continue;
15088
+ }
15089
+ const key = stripKeyQuotes(line.text.slice(0, colon).trim());
15090
+ if (key === "") {
15091
+ i++;
15092
+ continue;
15093
+ }
15094
+ const rest = line.text.slice(colon + 1).trim();
15095
+ if (rest !== "") {
15096
+ out[key] = parseFlowValue(rest);
15097
+ i++;
15098
+ } else {
15099
+ const childIndent = lines[i + 1]?.indent ?? line.indent + 1;
15100
+ let j = i + 1;
15101
+ while (j < lines.length && (lines[j]?.indent ?? -1) >= childIndent) j++;
15102
+ if (j === i + 1) {
15103
+ out[key] = null;
15104
+ i++;
15105
+ } else {
15106
+ const childBlock = lines.slice(i + 1, j);
15107
+ const firstChild = childBlock[0];
15108
+ if (firstChild?.text.trimStart().startsWith("- ")) {
15109
+ out[key] = parseSequence(childBlock, childIndent);
15110
+ } else {
15111
+ out[key] = parseMapping(childBlock, 0, childIndent).value;
15112
+ }
15113
+ i = j;
15114
+ }
15115
+ }
15116
+ }
15117
+ return { value: out, next: i };
15118
+ }
15119
+ function parseSequence(lines, indent) {
15120
+ const out = [];
15121
+ let i = 0;
15122
+ while (i < lines.length) {
15123
+ const line = lines[i];
15124
+ if (!line) break;
15125
+ if (line.indent < indent) break;
15126
+ const t = line.text.trimStart();
15127
+ if (!t.startsWith("- ")) {
15128
+ i++;
15129
+ continue;
15130
+ }
15131
+ const item = t.slice(2).trim();
15132
+ if (item === "") {
15133
+ const childIndent = lines[i + 1]?.indent ?? line.indent + 1;
15134
+ let j = i + 1;
15135
+ while (j < lines.length && (lines[j]?.indent ?? -1) >= childIndent) j++;
15136
+ out.push(parseMapping(lines.slice(i + 1, j), 0, childIndent).value);
15137
+ i = j;
15138
+ } else {
15139
+ out.push(parseFlowValue(item));
15140
+ i++;
15141
+ }
15142
+ }
15143
+ return out;
15144
+ }
15145
+ function parseFrontmatterBlock(block) {
15146
+ const lines = tokenize(block);
15147
+ if (lines.length === 0) return null;
15148
+ const { value } = parseMapping(lines, 0, 0);
15149
+ return value;
15150
+ }
15151
+
15152
+ // src/core/frontmatter.ts
14708
15153
  var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
14709
15154
  var MODEL_LINE_RE = /^model:[ \t]*(.*)$/m;
15155
+ var RESERVED_AGENT_KEYS = /* @__PURE__ */ new Set([
15156
+ "name",
15157
+ "mode",
15158
+ "description",
15159
+ "permission",
15160
+ "color",
15161
+ "tools",
15162
+ "prompt",
15163
+ "steps",
15164
+ "maxSteps"
15165
+ ]);
14710
15166
  function cleanModelValue(raw) {
14711
15167
  let v = raw;
14712
15168
  const hash2 = v.search(/[ \t]#/);
@@ -14735,6 +15191,55 @@ function setFrontmatterModel(content, model) {
14735
15191
  const nextFm = fm[0].slice(0, blockStart) + nextBlock + fm[0].slice(blockStart + block.length);
14736
15192
  return nextFm + content.slice(fm[0].length);
14737
15193
  }
15194
+ function getFrontmatterOptions(content) {
15195
+ const fm = FRONTMATTER_RE.exec(content);
15196
+ if (!fm?.[1]) return {};
15197
+ const parsed = parseFrontmatterBlock(fm[1]);
15198
+ if (!parsed) return {};
15199
+ const out = {};
15200
+ for (const [k, v] of Object.entries(parsed)) {
15201
+ if (k === "model" || RESERVED_AGENT_KEYS.has(k)) continue;
15202
+ out[k] = v;
15203
+ }
15204
+ return out;
15205
+ }
15206
+ function setFrontmatterOptions(content, options) {
15207
+ const fm = FRONTMATTER_RE.exec(content);
15208
+ if (!fm?.[1]) throw new Error("no frontmatter block");
15209
+ const block = fm[1];
15210
+ const keys = Object.keys(options);
15211
+ if (keys.length === 0) return content;
15212
+ const eol = block.includes("\r\n") ? "\r\n" : "\n";
15213
+ const lines = block.split(/\r?\n/);
15214
+ const handled = /* @__PURE__ */ new Set();
15215
+ for (let i = 0; i < lines.length; i++) {
15216
+ const m = /^([A-Za-z0-9_-]+):[ \t]*(.*)$/.exec(lines[i] ?? "");
15217
+ if (!m) continue;
15218
+ const key = m[1] ?? "";
15219
+ if (!(key in options)) continue;
15220
+ handled.add(key);
15221
+ let end = i + 1;
15222
+ while (end < lines.length && /^[ \t]+/.test(lines[end] ?? "")) end++;
15223
+ const value = options[key];
15224
+ if (value === null || value === void 0) {
15225
+ lines.splice(i, end - i);
15226
+ i--;
15227
+ } else {
15228
+ lines.splice(i, end - i, `${key}: ${serializeOptionValue(value)}`);
15229
+ }
15230
+ }
15231
+ const appended = [];
15232
+ for (const [key, value] of Object.entries(options)) {
15233
+ if (handled.has(key)) continue;
15234
+ if (value === null || value === void 0) continue;
15235
+ appended.push(`${key}: ${serializeOptionValue(value)}`);
15236
+ }
15237
+ const nextBlock = appended.length > 0 ? lines.join(eol) + eol + appended.join(eol) : lines.join(eol);
15238
+ if (nextBlock === block) return content;
15239
+ const blockStart = fm[0].indexOf(block);
15240
+ const nextFm = fm[0].slice(0, blockStart) + nextBlock + fm[0].slice(blockStart + block.length);
15241
+ return nextFm + content.slice(fm[0].length);
15242
+ }
14738
15243
  function agentFilePath(agentsDir, name) {
14739
15244
  return path3.join(agentsDir, `${name}.md`);
14740
15245
  }
@@ -14748,7 +15253,7 @@ async function listAgentFiles(agentsDir) {
14748
15253
  }
14749
15254
  return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -".md".length)).sort();
14750
15255
  }
14751
- async function readAgentModels(agentsDir) {
15256
+ async function readAgentEntries(agentsDir) {
14752
15257
  const out = {};
14753
15258
  for (const name of await listAgentFiles(agentsDir)) {
14754
15259
  const filePath = agentFilePath(agentsDir, name);
@@ -14759,10 +15264,17 @@ async function readAgentModels(agentsDir) {
14759
15264
  throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
14760
15265
  }
14761
15266
  const model = getFrontmatterModel(content);
14762
- if (model !== null) out[name] = model;
15267
+ if (model === null) continue;
15268
+ out[name] = { model, options: getFrontmatterOptions(content) };
14763
15269
  }
14764
15270
  return out;
14765
15271
  }
15272
+ async function readAgentModels(agentsDir) {
15273
+ const entries = await readAgentEntries(agentsDir);
15274
+ const out = {};
15275
+ for (const [name, entry] of Object.entries(entries)) out[name] = entry.model;
15276
+ return out;
15277
+ }
14766
15278
  async function readAgentFileStrict(agentsDir, name) {
14767
15279
  const filePath = agentFilePath(agentsDir, name);
14768
15280
  if (!existsSync2(filePath)) {
@@ -14778,7 +15290,7 @@ async function readAgentFileStrict(agentsDir, name) {
14778
15290
  if (model === null) {
14779
15291
  throw new AgentFileError(name, filePath, "no frontmatter `model:` line to rewrite");
14780
15292
  }
14781
- return { filePath, content, model };
15293
+ return { filePath, content, model, options: getFrontmatterOptions(content) };
14782
15294
  }
14783
15295
 
14784
15296
  // src/core/stack-manager.ts
@@ -14964,6 +15476,9 @@ function collectModelRefs(stack) {
14964
15476
  const refs = [];
14965
15477
  for (const [k, v] of Object.entries(stack.agents)) {
14966
15478
  refs.push({ path: `agents.${k}.model`, modelId: v.model });
15479
+ for (const [index, candidate] of (v.fallbacks ?? []).entries()) {
15480
+ refs.push({ path: `agents.${k}.fallbacks.${index}.model`, modelId: candidate.model });
15481
+ }
14967
15482
  }
14968
15483
  return refs;
14969
15484
  }
@@ -15036,15 +15551,14 @@ async function applyStack(paths, name, options = {}) {
15036
15551
  const pending = [];
15037
15552
  for (const [agent, entry] of Object.entries(target.agents)) {
15038
15553
  const { filePath, content, model } = await readAgentFileStrict(paths.agentsDir, agent);
15039
- pending.push({
15040
- agent,
15041
- filePath,
15042
- next: model === entry.model ? null : setFrontmatterModel(content, entry.model)
15043
- });
15554
+ const wantOptions = entryOptions(entry);
15555
+ const afterOptions = setFrontmatterOptions(content, wantOptions);
15556
+ const nextContent = entry.model === model ? afterOptions : setFrontmatterModel(afterOptions, entry.model);
15557
+ pending.push({ agent, filePath, next: nextContent === content ? null : nextContent });
15044
15558
  }
15045
15559
  const prevState = await readState(paths.statePath);
15046
15560
  const prevActive = prevState?.active ?? null;
15047
- const displaced = { agents: modelsToStackAgents(await readAgentModels(paths.agentsDir)) };
15561
+ const displaced = { agents: await captureAgents(paths) };
15048
15562
  const historyId = await appendHistory(
15049
15563
  paths.historyDir,
15050
15564
  prevActive ?? "(none)",
@@ -15062,7 +15576,20 @@ async function applyStack(paths, name, options = {}) {
15062
15576
  version: 1,
15063
15577
  active: name,
15064
15578
  previousActive: prevActive,
15065
- lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString()
15579
+ lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString(),
15580
+ fallbackAgents: Object.fromEntries(
15581
+ Object.entries(target.agents).filter(([, entry]) => entry.fallbacks?.length).map(([agent, entry]) => {
15582
+ const variant = "variant" in entry ? entry.variant : displaced.agents[agent]?.variant;
15583
+ return [
15584
+ agent,
15585
+ {
15586
+ model: entry.model,
15587
+ variant: typeof variant === "string" ? variant : void 0,
15588
+ fallbacks: entry.fallbacks
15589
+ }
15590
+ ];
15591
+ })
15592
+ )
15066
15593
  });
15067
15594
  await trimHistory(paths.historyDir).catch(() => {
15068
15595
  });
@@ -15074,14 +15601,40 @@ async function applyStack(paths, name, options = {}) {
15074
15601
  restartRequired: true
15075
15602
  };
15076
15603
  }
15077
- function modelsToStackAgents(models) {
15604
+ function entryOptions(entry) {
15605
+ const rec = entry;
15078
15606
  const out = {};
15079
- for (const k of Object.keys(models).sort()) {
15080
- const model = models[k];
15081
- if (model !== void 0) out[k] = { model };
15607
+ for (const [k, v] of Object.entries(rec)) {
15608
+ if (k === "model" || k === "fallbacks" || RESERVED_AGENT_KEYS.has(k)) continue;
15609
+ out[k] = v;
15082
15610
  }
15083
15611
  return out;
15084
15612
  }
15613
+ function entriesToStackAgents(entries) {
15614
+ const out = {};
15615
+ for (const name of Object.keys(entries).sort()) {
15616
+ const entry = entries[name];
15617
+ if (!entry) continue;
15618
+ const { model, options } = entry;
15619
+ const stackEntry = { model };
15620
+ for (const [k, v] of Object.entries(options)) {
15621
+ if (k !== "fallbacks") stackEntry[k] = v;
15622
+ }
15623
+ out[name] = stackEntry;
15624
+ }
15625
+ return out;
15626
+ }
15627
+ async function captureAgents(paths) {
15628
+ const agents = entriesToStackAgents(await readAgentEntries(paths.agentsDir));
15629
+ const state = await readState(paths.statePath);
15630
+ for (const [name, entry] of Object.entries(agents)) {
15631
+ const routing = state?.fallbackAgents?.[name];
15632
+ if (routing && routing.model === entry.model && (routing.variant ?? void 0) === (entry.variant ?? void 0)) {
15633
+ entry.fallbacks = routing.fallbacks;
15634
+ }
15635
+ }
15636
+ return agents;
15637
+ }
15085
15638
  async function back(paths, n = 1, options = {}) {
15086
15639
  if (n < 1) throw new UserError("`back -n` must be at least 1.");
15087
15640
  const state = await readState(paths.statePath);
@@ -15115,8 +15668,7 @@ async function captureStack(paths, name, options = {}) {
15115
15668
  if (existsSync5(dest) && !options.force) {
15116
15669
  throw new UserError(`Stack "${name}" already exists. Use --force to overwrite.`);
15117
15670
  }
15118
- const models = await readAgentModels(paths.agentsDir);
15119
- const agents = modelsToStackAgents(models);
15671
+ const agents = await captureAgents(paths);
15120
15672
  if (Object.keys(agents).length === 0) {
15121
15673
  throw new UserError(
15122
15674
  `No agent .md files with a frontmatter \`model:\` line found in ${paths.agentsDir}.`
@@ -15128,11 +15680,17 @@ async function captureStack(paths, name, options = {}) {
15128
15680
  }
15129
15681
 
15130
15682
  // src/version.ts
15131
- var VERSION = "1.0.6";
15683
+ var VERSION = "1.1.0";
15132
15684
 
15133
15685
  // src/plugin.ts
15134
15686
  async function safeToast(client, message, variant = "success") {
15135
15687
  try {
15688
+ if (client.tui?.showToast) {
15689
+ const result = await client.tui.showToast({ body: { message, variant, duration: 15e3 } });
15690
+ if (result && typeof result === "object" && "error" in result && result.error)
15691
+ throw result.error;
15692
+ return;
15693
+ }
15136
15694
  if (client.tui?.toast?.show) {
15137
15695
  await client.tui.toast.show({ body: { message, variant } });
15138
15696
  return;
@@ -15154,6 +15712,30 @@ function errOut(e) {
15154
15712
  var AgentRouterPlugin = async (ctx) => {
15155
15713
  const paths = await resolvePathsWithConfig();
15156
15714
  const client = ctx.client;
15715
+ const stateBytes = () => {
15716
+ try {
15717
+ return readFileSync(paths.statePath, "utf8");
15718
+ } catch {
15719
+ return void 0;
15720
+ }
15721
+ };
15722
+ const initialState = stateBytes();
15723
+ const captured = await captureAgents(paths).catch(() => ({}));
15724
+ const routes = Object.fromEntries(
15725
+ Object.entries(captured).flatMap(([agent, entry]) => {
15726
+ const parsed = RoutingEntrySchema.safeParse({
15727
+ model: entry.model,
15728
+ variant: entry.variant,
15729
+ fallbacks: entry.fallbacks
15730
+ });
15731
+ return parsed.success && parsed.data.fallbacks?.length ? [[agent, parsed.data]] : [];
15732
+ })
15733
+ );
15734
+ const failover = createFailover(routes, async (message) => {
15735
+ await client.app?.log?.({ body: { service: "agent-router", level: "warn", message } }).catch(() => {
15736
+ });
15737
+ await safeToast(client, message, "warning");
15738
+ });
15157
15739
  await client.app?.log?.({
15158
15740
  body: {
15159
15741
  service: "agent-router",
@@ -15168,6 +15750,11 @@ var AgentRouterPlugin = async (ctx) => {
15168
15750
  }).catch(() => {
15169
15751
  });
15170
15752
  return {
15753
+ event: failover.event,
15754
+ "chat.message": async (input, output) => {
15755
+ if (stateBytes() !== initialState) failover.disable();
15756
+ await failover.message(input, output);
15757
+ },
15171
15758
  tool: {
15172
15759
  router_status: tool({
15173
15760
  description: "Report the active agent-router stack, the current agent \u2192 model frontmatter mapping, and all available stacks.",
@@ -15208,6 +15795,7 @@ var AgentRouterPlugin = async (ctx) => {
15208
15795
  },
15209
15796
  async execute(args) {
15210
15797
  try {
15798
+ failover.disable();
15211
15799
  const r = await applyStack(paths, args.name, {
15212
15800
  validate: args.validate ?? true
15213
15801
  });
@@ -15251,12 +15839,7 @@ var AgentRouterPlugin = async (ctx) => {
15251
15839
  try {
15252
15840
  let stack;
15253
15841
  if (args.active) {
15254
- const models = await readAgentModels(paths.agentsDir);
15255
- stack = {
15256
- agents: Object.fromEntries(
15257
- Object.entries(models).map(([k, model]) => [k, { model }])
15258
- )
15259
- };
15842
+ stack = { agents: await captureAgents(paths) };
15260
15843
  } else if (args.name) {
15261
15844
  stack = await readStack(paths, args.name);
15262
15845
  } else {
@@ -15280,6 +15863,7 @@ var AgentRouterPlugin = async (ctx) => {
15280
15863
  },
15281
15864
  async execute(args) {
15282
15865
  try {
15866
+ failover.disable();
15283
15867
  const r = await back(paths, args.n ?? 1);
15284
15868
  await safeToast(
15285
15869
  client,