@dylanrussell/agent-router 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/assets/logo.svg +45 -0
- package/dist/cli.d.ts +23 -0
- package/dist/cli.js +1259 -0
- package/dist/cli.js.map +1 -0
- package/dist/plugin.d.ts +30 -0
- package/dist/plugin.js +787 -0
- package/dist/plugin.js.map +1 -0
- package/dist/tui.d.ts +99 -0
- package/dist/tui.js +1048 -0
- package/dist/tui.js.map +1 -0
- package/package.json +95 -0
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
// src/plugin.ts
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
// src/core/config.ts
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { readFile } from "fs/promises";
|
|
7
|
+
import { homedir as homedir2 } from "os";
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
|
|
10
|
+
// src/core/errors.ts
|
|
11
|
+
var RouterError = class extends Error {
|
|
12
|
+
/** CLI exit code for this error class. Subclasses override. */
|
|
13
|
+
static exitCode = 1;
|
|
14
|
+
name = "RouterError";
|
|
15
|
+
};
|
|
16
|
+
var StackNotFoundError = class extends RouterError {
|
|
17
|
+
constructor(stackName, available) {
|
|
18
|
+
super(
|
|
19
|
+
`Stack "${stackName}" not found. Available: ${available.length ? available.join(", ") : "(none)"}.`
|
|
20
|
+
);
|
|
21
|
+
this.stackName = stackName;
|
|
22
|
+
this.available = available;
|
|
23
|
+
}
|
|
24
|
+
stackName;
|
|
25
|
+
available;
|
|
26
|
+
static exitCode = 2;
|
|
27
|
+
name = "StackNotFoundError";
|
|
28
|
+
};
|
|
29
|
+
var AgentFileError = class extends RouterError {
|
|
30
|
+
constructor(agentName, filePath, reason) {
|
|
31
|
+
super(`Agent "${agentName}" (${filePath}): ${reason}`);
|
|
32
|
+
this.agentName = agentName;
|
|
33
|
+
this.filePath = filePath;
|
|
34
|
+
}
|
|
35
|
+
agentName;
|
|
36
|
+
filePath;
|
|
37
|
+
static exitCode = 2;
|
|
38
|
+
name = "AgentFileError";
|
|
39
|
+
};
|
|
40
|
+
var ValidationError = class extends RouterError {
|
|
41
|
+
constructor(message, path8, issues) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.path = path8;
|
|
44
|
+
this.issues = issues;
|
|
45
|
+
}
|
|
46
|
+
path;
|
|
47
|
+
issues;
|
|
48
|
+
static exitCode = 4;
|
|
49
|
+
name = "ValidationError";
|
|
50
|
+
};
|
|
51
|
+
var ModelValidationError = class extends RouterError {
|
|
52
|
+
constructor(stackName, missing) {
|
|
53
|
+
super(
|
|
54
|
+
`Stack "${stackName}" references ${missing.length} unreachable model ID${missing.length === 1 ? "" : "s"}.`
|
|
55
|
+
);
|
|
56
|
+
this.stackName = stackName;
|
|
57
|
+
this.missing = missing;
|
|
58
|
+
}
|
|
59
|
+
stackName;
|
|
60
|
+
missing;
|
|
61
|
+
static exitCode = 4;
|
|
62
|
+
name = "ModelValidationError";
|
|
63
|
+
};
|
|
64
|
+
var IOError = class extends RouterError {
|
|
65
|
+
static exitCode = 3;
|
|
66
|
+
name = "IOError";
|
|
67
|
+
causedBy;
|
|
68
|
+
constructor(message, causedBy) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.causedBy = causedBy;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
var UserError = class extends RouterError {
|
|
74
|
+
static exitCode = 1;
|
|
75
|
+
name = "UserError";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// src/core/paths.ts
|
|
79
|
+
import { homedir } from "os";
|
|
80
|
+
import path from "path";
|
|
81
|
+
function resolvePaths(options = {}) {
|
|
82
|
+
const env = options.env ?? process.env;
|
|
83
|
+
const opencodeConfigDir = options.opencodeConfigDir ?? path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode");
|
|
84
|
+
const routerHome = options.routerHome ?? env.AGENT_ROUTER_HOME ?? env.OMO_ROUTER_HOME ?? path.join(opencodeConfigDir, "agent-router");
|
|
85
|
+
const agentsDir = options.agentsDir ?? env.AGENT_ROUTER_AGENTS_DIR ?? path.join(opencodeConfigDir, "agents");
|
|
86
|
+
const stacksDir = options.stacksDir ?? env.AGENT_ROUTER_STACKS_DIR ?? path.join(routerHome, "stacks");
|
|
87
|
+
return {
|
|
88
|
+
opencodeConfigDir,
|
|
89
|
+
opencodeJsonPath: path.join(opencodeConfigDir, "opencode.json"),
|
|
90
|
+
tuiJsonPath: path.join(opencodeConfigDir, "tui.json"),
|
|
91
|
+
agentsDir,
|
|
92
|
+
opencodeBackupsDir: path.join(opencodeConfigDir, ".backups"),
|
|
93
|
+
routerHome,
|
|
94
|
+
statePath: path.join(routerHome, "state.json"),
|
|
95
|
+
stacksDir,
|
|
96
|
+
historyDir: path.join(routerHome, "history")
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/core/schema.ts
|
|
101
|
+
import { z } from "zod";
|
|
102
|
+
var StateFileSchema = z.object({
|
|
103
|
+
version: z.literal(1),
|
|
104
|
+
active: z.string().min(1),
|
|
105
|
+
previousActive: z.string().min(1).nullable(),
|
|
106
|
+
lastSwitchedAt: z.string().min(1)
|
|
107
|
+
}).strict();
|
|
108
|
+
var AgentEntrySchema = z.object({
|
|
109
|
+
model: z.string().min(1)
|
|
110
|
+
}).passthrough();
|
|
111
|
+
var StackFileSchema = z.object({
|
|
112
|
+
agents: z.record(z.string(), AgentEntrySchema)
|
|
113
|
+
}).passthrough().refine((s) => Object.keys(s.agents).length > 0, {
|
|
114
|
+
message: "Stack file must map at least one agent in `agents`."
|
|
115
|
+
});
|
|
116
|
+
var ConfigFileSchema = z.object({
|
|
117
|
+
/** Where the agent .md files live. Default: `${opencodeConfigDir}/agents`. */
|
|
118
|
+
agentsDir: z.string().min(1).optional(),
|
|
119
|
+
/** Where named stacks live. Default: `${routerHome}/stacks`. */
|
|
120
|
+
stacksDir: z.string().min(1).optional()
|
|
121
|
+
}).strict();
|
|
122
|
+
var OpencodeJsonSchema = z.object({
|
|
123
|
+
plugin: z.array(z.string()).optional()
|
|
124
|
+
}).passthrough();
|
|
125
|
+
|
|
126
|
+
// src/core/config.ts
|
|
127
|
+
var CONFIG_FILE_NAME = "config.json";
|
|
128
|
+
function expandHome(p) {
|
|
129
|
+
if (p === "~") return homedir2();
|
|
130
|
+
if (p.startsWith("~/")) return path2.join(homedir2(), p.slice(2));
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
async function readConfigFile(routerHome) {
|
|
134
|
+
const configPath = path2.join(routerHome, CONFIG_FILE_NAME);
|
|
135
|
+
if (!existsSync(configPath)) return null;
|
|
136
|
+
let raw;
|
|
137
|
+
try {
|
|
138
|
+
raw = await readFile(configPath, "utf8");
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
throw new IOError(`Failed to read ${configPath}: ${cause.message}`, cause);
|
|
141
|
+
}
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(raw);
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
throw new ValidationError(
|
|
147
|
+
`agent-router config.json is not valid JSON: ${cause.message}`,
|
|
148
|
+
configPath
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const result = ConfigFileSchema.safeParse(parsed);
|
|
152
|
+
if (!result.success) {
|
|
153
|
+
throw new ValidationError(
|
|
154
|
+
`agent-router config.json failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
155
|
+
configPath,
|
|
156
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return result.data;
|
|
160
|
+
}
|
|
161
|
+
function normalizeConfigPath(routerHome, p) {
|
|
162
|
+
const expanded = expandHome(p);
|
|
163
|
+
return path2.isAbsolute(expanded) ? expanded : path2.resolve(routerHome, expanded);
|
|
164
|
+
}
|
|
165
|
+
async function resolvePathsWithConfig(options = {}) {
|
|
166
|
+
const base = resolvePaths(options);
|
|
167
|
+
const config = await readConfigFile(base.routerHome);
|
|
168
|
+
if (!config) return base;
|
|
169
|
+
const next = { ...options };
|
|
170
|
+
if (options.agentsDir === void 0 && config.agentsDir) {
|
|
171
|
+
next.agentsDir = normalizeConfigPath(
|
|
172
|
+
base.routerHome,
|
|
173
|
+
config.agentsDir
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (options.stacksDir === void 0 && config.stacksDir) {
|
|
177
|
+
next.stacksDir = normalizeConfigPath(
|
|
178
|
+
base.routerHome,
|
|
179
|
+
config.stacksDir
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return resolvePaths(next);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/core/frontmatter.ts
|
|
186
|
+
import { existsSync as existsSync2 } from "fs";
|
|
187
|
+
import { readFile as readFile2, readdir } from "fs/promises";
|
|
188
|
+
import path3 from "path";
|
|
189
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
190
|
+
var MODEL_LINE_RE = /^model:[ \t]*(.*)$/m;
|
|
191
|
+
function cleanModelValue(raw) {
|
|
192
|
+
let v = raw;
|
|
193
|
+
const hash = v.search(/[ \t]#/);
|
|
194
|
+
if (hash >= 0) v = v.slice(0, hash);
|
|
195
|
+
v = v.trim();
|
|
196
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2 || v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
197
|
+
v = v.slice(1, -1);
|
|
198
|
+
}
|
|
199
|
+
return v;
|
|
200
|
+
}
|
|
201
|
+
function getFrontmatterModel(content) {
|
|
202
|
+
const fm = FRONTMATTER_RE.exec(content);
|
|
203
|
+
if (!fm?.[1]) return null;
|
|
204
|
+
const line = MODEL_LINE_RE.exec(fm[1]);
|
|
205
|
+
if (!line) return null;
|
|
206
|
+
const value = cleanModelValue(line[1] ?? "");
|
|
207
|
+
return value.length > 0 ? value : null;
|
|
208
|
+
}
|
|
209
|
+
function setFrontmatterModel(content, model) {
|
|
210
|
+
const fm = FRONTMATTER_RE.exec(content);
|
|
211
|
+
if (!fm?.[1]) throw new Error("no frontmatter block");
|
|
212
|
+
const block = fm[1];
|
|
213
|
+
if (!MODEL_LINE_RE.test(block)) throw new Error("no `model:` line in frontmatter");
|
|
214
|
+
const nextBlock = block.replace(MODEL_LINE_RE, () => `model: ${model}`);
|
|
215
|
+
const blockStart = fm[0].indexOf(block);
|
|
216
|
+
const nextFm = fm[0].slice(0, blockStart) + nextBlock + fm[0].slice(blockStart + block.length);
|
|
217
|
+
return nextFm + content.slice(fm[0].length);
|
|
218
|
+
}
|
|
219
|
+
function agentFilePath(agentsDir, name) {
|
|
220
|
+
return path3.join(agentsDir, `${name}.md`);
|
|
221
|
+
}
|
|
222
|
+
async function listAgentFiles(agentsDir) {
|
|
223
|
+
if (!existsSync2(agentsDir)) return [];
|
|
224
|
+
let names;
|
|
225
|
+
try {
|
|
226
|
+
names = await readdir(agentsDir);
|
|
227
|
+
} catch (cause) {
|
|
228
|
+
throw new IOError(`Failed to read agents dir: ${cause.message}`, cause);
|
|
229
|
+
}
|
|
230
|
+
return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -".md".length)).sort();
|
|
231
|
+
}
|
|
232
|
+
async function readAgentModels(agentsDir) {
|
|
233
|
+
const out = {};
|
|
234
|
+
for (const name of await listAgentFiles(agentsDir)) {
|
|
235
|
+
const filePath = agentFilePath(agentsDir, name);
|
|
236
|
+
let content;
|
|
237
|
+
try {
|
|
238
|
+
content = await readFile2(filePath, "utf8");
|
|
239
|
+
} catch (cause) {
|
|
240
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
241
|
+
}
|
|
242
|
+
const model = getFrontmatterModel(content);
|
|
243
|
+
if (model !== null) out[name] = model;
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
async function readAgentFileStrict(agentsDir, name) {
|
|
248
|
+
const filePath = agentFilePath(agentsDir, name);
|
|
249
|
+
if (!existsSync2(filePath)) {
|
|
250
|
+
throw new AgentFileError(name, filePath, "file does not exist");
|
|
251
|
+
}
|
|
252
|
+
let content;
|
|
253
|
+
try {
|
|
254
|
+
content = await readFile2(filePath, "utf8");
|
|
255
|
+
} catch (cause) {
|
|
256
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
257
|
+
}
|
|
258
|
+
const model = getFrontmatterModel(content);
|
|
259
|
+
if (model === null) {
|
|
260
|
+
throw new AgentFileError(name, filePath, "no frontmatter `model:` line to rewrite");
|
|
261
|
+
}
|
|
262
|
+
return { filePath, content, model };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// src/core/stack-manager.ts
|
|
266
|
+
import { existsSync as existsSync5 } from "fs";
|
|
267
|
+
import { copyFile as copyFile2, mkdir as mkdir4, readFile as readFile4, readdir as readdir3, unlink as unlink3 } from "fs/promises";
|
|
268
|
+
import path7 from "path";
|
|
269
|
+
|
|
270
|
+
// src/core/atomic-write.ts
|
|
271
|
+
import { randomBytes } from "crypto";
|
|
272
|
+
import { mkdir, realpath, rename, unlink, writeFile } from "fs/promises";
|
|
273
|
+
import path4 from "path";
|
|
274
|
+
async function resolveDestination(destPath) {
|
|
275
|
+
try {
|
|
276
|
+
return await realpath(destPath);
|
|
277
|
+
} catch {
|
|
278
|
+
try {
|
|
279
|
+
const dir = await realpath(path4.dirname(destPath));
|
|
280
|
+
return path4.join(dir, path4.basename(destPath));
|
|
281
|
+
} catch {
|
|
282
|
+
return destPath;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function atomicWriteFile(destPath, contents) {
|
|
287
|
+
await mkdir(path4.dirname(destPath), { recursive: true });
|
|
288
|
+
const dest = await resolveDestination(destPath);
|
|
289
|
+
const dir = path4.dirname(dest);
|
|
290
|
+
const tmp = path4.join(
|
|
291
|
+
dir,
|
|
292
|
+
`.${path4.basename(dest)}.artmp-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
293
|
+
);
|
|
294
|
+
try {
|
|
295
|
+
await writeFile(tmp, contents, { encoding: "utf8", mode: 420 });
|
|
296
|
+
await rename(tmp, dest);
|
|
297
|
+
} catch (cause) {
|
|
298
|
+
await unlink(tmp).catch(() => {
|
|
299
|
+
});
|
|
300
|
+
throw new IOError(`Atomic write failed for ${destPath}: ${cause.message}`, cause);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function atomicWriteJson(destPath, value) {
|
|
304
|
+
const text = `${JSON.stringify(value, null, 2)}
|
|
305
|
+
`;
|
|
306
|
+
await atomicWriteFile(destPath, text);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/core/history.ts
|
|
310
|
+
import { mkdir as mkdir3, readdir as readdir2, unlink as unlink2 } from "fs/promises";
|
|
311
|
+
import path6 from "path";
|
|
312
|
+
|
|
313
|
+
// src/core/backups.ts
|
|
314
|
+
import { existsSync as existsSync3 } from "fs";
|
|
315
|
+
import { copyFile, mkdir as mkdir2 } from "fs/promises";
|
|
316
|
+
import path5 from "path";
|
|
317
|
+
function timestampStamp(date = /* @__PURE__ */ new Date()) {
|
|
318
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/core/history.ts
|
|
322
|
+
function filename(fromStack, toStack, when = /* @__PURE__ */ new Date()) {
|
|
323
|
+
const stamp = timestampStamp(when);
|
|
324
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._()-]/g, "_");
|
|
325
|
+
return `${stamp}__${safe(fromStack)}-to-${safe(toStack)}.json`;
|
|
326
|
+
}
|
|
327
|
+
function parseHistoryFilename(name) {
|
|
328
|
+
if (!name.endsWith(".json")) return null;
|
|
329
|
+
const stem = name.slice(0, -".json".length);
|
|
330
|
+
const sep = stem.indexOf("__");
|
|
331
|
+
if (sep < 0) return null;
|
|
332
|
+
const stamp = stem.slice(0, sep);
|
|
333
|
+
const rest = stem.slice(sep + 2);
|
|
334
|
+
const toIdx = rest.lastIndexOf("-to-");
|
|
335
|
+
if (toIdx < 0) return null;
|
|
336
|
+
return {
|
|
337
|
+
id: stem,
|
|
338
|
+
timestamp: stamp,
|
|
339
|
+
fromStack: rest.slice(0, toIdx),
|
|
340
|
+
toStack: rest.slice(toIdx + "-to-".length)
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
async function appendHistory(historyDir, fromStack, toStack, displacedContent) {
|
|
344
|
+
await mkdir3(historyDir, { recursive: true });
|
|
345
|
+
const name = filename(fromStack, toStack);
|
|
346
|
+
await atomicWriteFile(path6.join(historyDir, name), displacedContent);
|
|
347
|
+
return name.slice(0, -".json".length);
|
|
348
|
+
}
|
|
349
|
+
async function listHistory(historyDir) {
|
|
350
|
+
let names;
|
|
351
|
+
try {
|
|
352
|
+
names = await readdir2(historyDir);
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
if (cause.code === "ENOENT") return [];
|
|
355
|
+
throw new IOError(`Failed to read history dir: ${cause.message}`, cause);
|
|
356
|
+
}
|
|
357
|
+
const entries = [];
|
|
358
|
+
for (const n of names) {
|
|
359
|
+
const parsed = parseHistoryFilename(n);
|
|
360
|
+
if (parsed) entries.push({ ...parsed, path: path6.join(historyDir, n) });
|
|
361
|
+
}
|
|
362
|
+
entries.sort((a, b) => a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0);
|
|
363
|
+
return entries;
|
|
364
|
+
}
|
|
365
|
+
async function trimHistory(historyDir, keep = 20) {
|
|
366
|
+
const all = await listHistory(historyDir);
|
|
367
|
+
if (all.length <= keep) return [];
|
|
368
|
+
const toDelete = all.slice(keep);
|
|
369
|
+
const deleted = [];
|
|
370
|
+
for (const e of toDelete) {
|
|
371
|
+
try {
|
|
372
|
+
await unlink2(e.path);
|
|
373
|
+
deleted.push(e.id);
|
|
374
|
+
} catch (cause) {
|
|
375
|
+
void cause;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return deleted;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// src/core/state.ts
|
|
382
|
+
import { existsSync as existsSync4 } from "fs";
|
|
383
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
384
|
+
async function readState(statePath) {
|
|
385
|
+
if (!existsSync4(statePath)) return null;
|
|
386
|
+
let raw;
|
|
387
|
+
try {
|
|
388
|
+
raw = await readFile3(statePath, "utf8");
|
|
389
|
+
} catch (cause) {
|
|
390
|
+
throw new IOError(`Failed to read ${statePath}: ${cause.message}`, cause);
|
|
391
|
+
}
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(raw);
|
|
395
|
+
} catch (cause) {
|
|
396
|
+
throw new ValidationError(
|
|
397
|
+
`state.json is not valid JSON: ${cause.message}`,
|
|
398
|
+
statePath
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
const result = StateFileSchema.safeParse(parsed);
|
|
402
|
+
if (!result.success) {
|
|
403
|
+
throw new ValidationError(
|
|
404
|
+
`state.json failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
405
|
+
statePath,
|
|
406
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return result.data;
|
|
410
|
+
}
|
|
411
|
+
async function writeState(statePath, state) {
|
|
412
|
+
const result = StateFileSchema.safeParse(state);
|
|
413
|
+
if (!result.success) {
|
|
414
|
+
throw new ValidationError(
|
|
415
|
+
`Refusing to write invalid state.json: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
416
|
+
statePath
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
await atomicWriteJson(statePath, result.data);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/core/validator.ts
|
|
423
|
+
import { execFile } from "child_process";
|
|
424
|
+
import { promisify } from "util";
|
|
425
|
+
var execFileAsync = promisify(execFile);
|
|
426
|
+
var DEFAULT_RUNNER = async () => {
|
|
427
|
+
const { stdout } = await execFileAsync("opencode", ["models"], {
|
|
428
|
+
encoding: "utf8",
|
|
429
|
+
timeout: 3e4,
|
|
430
|
+
maxBuffer: 4 * 1024 * 1024
|
|
431
|
+
});
|
|
432
|
+
return stdout;
|
|
433
|
+
};
|
|
434
|
+
function parseModelList(stdout) {
|
|
435
|
+
const out = /* @__PURE__ */ new Set();
|
|
436
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
437
|
+
const line = raw.trim();
|
|
438
|
+
if (!line) continue;
|
|
439
|
+
if (line.startsWith("#")) continue;
|
|
440
|
+
out.add(line);
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
function collectModelRefs(stack) {
|
|
445
|
+
const refs = [];
|
|
446
|
+
for (const [k, v] of Object.entries(stack.agents)) {
|
|
447
|
+
refs.push({ path: `agents.${k}.model`, modelId: v.model });
|
|
448
|
+
}
|
|
449
|
+
return refs;
|
|
450
|
+
}
|
|
451
|
+
async function validateStack(stack, options = {}) {
|
|
452
|
+
const runner = options.runOpencodeModels ?? DEFAULT_RUNNER;
|
|
453
|
+
const stdout = await runner();
|
|
454
|
+
const available = parseModelList(stdout);
|
|
455
|
+
const refs = collectModelRefs(stack);
|
|
456
|
+
const missing = refs.filter((r) => !available.has(r.modelId));
|
|
457
|
+
return { ok: missing.length === 0, checked: refs.length, missing, available };
|
|
458
|
+
}
|
|
459
|
+
async function validateStackOrThrow(stackName, stack, options = {}) {
|
|
460
|
+
const result = await validateStack(stack, options);
|
|
461
|
+
if (!result.ok) throw new ModelValidationError(stackName, result.missing);
|
|
462
|
+
return result;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// src/core/stack-manager.ts
|
|
466
|
+
async function listStacks(paths) {
|
|
467
|
+
if (!existsSync5(paths.stacksDir)) return [];
|
|
468
|
+
let names;
|
|
469
|
+
try {
|
|
470
|
+
names = await readdir3(paths.stacksDir);
|
|
471
|
+
} catch (cause) {
|
|
472
|
+
throw new IOError(`Failed to read stacks dir: ${cause.message}`, cause);
|
|
473
|
+
}
|
|
474
|
+
return names.filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -".json".length)).sort();
|
|
475
|
+
}
|
|
476
|
+
function stackPath(paths, name) {
|
|
477
|
+
return path7.join(paths.stacksDir, `${name}.json`);
|
|
478
|
+
}
|
|
479
|
+
async function readStack(paths, name) {
|
|
480
|
+
const filePath = stackPath(paths, name);
|
|
481
|
+
if (!existsSync5(filePath)) {
|
|
482
|
+
throw new StackNotFoundError(name, await listStacks(paths));
|
|
483
|
+
}
|
|
484
|
+
const raw = await readFile4(filePath, "utf8").catch((cause) => {
|
|
485
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
486
|
+
});
|
|
487
|
+
let parsed;
|
|
488
|
+
try {
|
|
489
|
+
parsed = JSON.parse(raw);
|
|
490
|
+
} catch (cause) {
|
|
491
|
+
throw new ValidationError(
|
|
492
|
+
`Stack "${name}" is not valid JSON: ${cause.message}`,
|
|
493
|
+
filePath
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
const result = StackFileSchema.safeParse(parsed);
|
|
497
|
+
if (!result.success) {
|
|
498
|
+
throw new ValidationError(
|
|
499
|
+
`Stack "${name}" failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
500
|
+
filePath,
|
|
501
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
return result.data;
|
|
505
|
+
}
|
|
506
|
+
async function getActiveStackName(paths) {
|
|
507
|
+
const state = await readState(paths.statePath);
|
|
508
|
+
return state?.active ?? null;
|
|
509
|
+
}
|
|
510
|
+
async function applyStack(paths, name, options = {}) {
|
|
511
|
+
const validate = options.validate ?? true;
|
|
512
|
+
const forceInvalid = options.forceInvalid ?? false;
|
|
513
|
+
const target = await readStack(paths, name);
|
|
514
|
+
if (validate && !forceInvalid) {
|
|
515
|
+
await validateStackOrThrow(name, target, options.validateOptions);
|
|
516
|
+
}
|
|
517
|
+
const pending = [];
|
|
518
|
+
for (const [agent, entry] of Object.entries(target.agents)) {
|
|
519
|
+
const { filePath, content, model } = await readAgentFileStrict(paths.agentsDir, agent);
|
|
520
|
+
pending.push({
|
|
521
|
+
agent,
|
|
522
|
+
filePath,
|
|
523
|
+
next: model === entry.model ? null : setFrontmatterModel(content, entry.model)
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
const prevState = await readState(paths.statePath);
|
|
527
|
+
const prevActive = prevState?.active ?? null;
|
|
528
|
+
const displaced = { agents: modelsToStackAgents(await readAgentModels(paths.agentsDir)) };
|
|
529
|
+
const historyId = await appendHistory(
|
|
530
|
+
paths.historyDir,
|
|
531
|
+
prevActive ?? "(none)",
|
|
532
|
+
name,
|
|
533
|
+
`${JSON.stringify(displaced, null, 2)}
|
|
534
|
+
`
|
|
535
|
+
);
|
|
536
|
+
const changed = [];
|
|
537
|
+
for (const p of pending) {
|
|
538
|
+
if (p.next === null) continue;
|
|
539
|
+
await atomicWriteFile(p.filePath, p.next);
|
|
540
|
+
changed.push(p.agent);
|
|
541
|
+
}
|
|
542
|
+
await writeState(paths.statePath, {
|
|
543
|
+
version: 1,
|
|
544
|
+
active: name,
|
|
545
|
+
previousActive: prevActive,
|
|
546
|
+
lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
547
|
+
});
|
|
548
|
+
await trimHistory(paths.historyDir).catch(() => {
|
|
549
|
+
});
|
|
550
|
+
return {
|
|
551
|
+
previous: prevActive,
|
|
552
|
+
current: name,
|
|
553
|
+
changed,
|
|
554
|
+
historyId,
|
|
555
|
+
restartRequired: true
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
function modelsToStackAgents(models) {
|
|
559
|
+
const out = {};
|
|
560
|
+
for (const k of Object.keys(models).sort()) {
|
|
561
|
+
const model = models[k];
|
|
562
|
+
if (model !== void 0) out[k] = { model };
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
async function back(paths, n = 1, options = {}) {
|
|
567
|
+
if (n < 1) throw new UserError("`back -n` must be at least 1.");
|
|
568
|
+
const state = await readState(paths.statePath);
|
|
569
|
+
if (!state || !state.previousActive) {
|
|
570
|
+
throw new UserError("No previous stack to revert to.");
|
|
571
|
+
}
|
|
572
|
+
if (n === 1) return applyStack(paths, state.previousActive, options);
|
|
573
|
+
const entries = await listHistory(paths.historyDir);
|
|
574
|
+
if (entries.length < n) {
|
|
575
|
+
throw new UserError(
|
|
576
|
+
`Cannot go back ${n} steps; only ${entries.length} switch${entries.length === 1 ? "" : "es"} in history.`
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
const target = entries[n - 1]?.fromStack;
|
|
580
|
+
if (!target || target === "(none)") {
|
|
581
|
+
throw new UserError(`Cannot go back ${n} steps to a non-stack target ("${target}").`);
|
|
582
|
+
}
|
|
583
|
+
return applyStack(paths, target, options);
|
|
584
|
+
}
|
|
585
|
+
var STACK_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
|
586
|
+
function assertValidStackName(name) {
|
|
587
|
+
if (!STACK_NAME_RE.test(name)) {
|
|
588
|
+
throw new UserError(
|
|
589
|
+
`Stack name "${name}" contains invalid characters. Allowed: A-Z a-z 0-9 . _ -`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function captureStack(paths, name, options = {}) {
|
|
594
|
+
assertValidStackName(name);
|
|
595
|
+
const dest = stackPath(paths, name);
|
|
596
|
+
if (existsSync5(dest) && !options.force) {
|
|
597
|
+
throw new UserError(`Stack "${name}" already exists. Use --force to overwrite.`);
|
|
598
|
+
}
|
|
599
|
+
const models = await readAgentModels(paths.agentsDir);
|
|
600
|
+
const agents = modelsToStackAgents(models);
|
|
601
|
+
if (Object.keys(agents).length === 0) {
|
|
602
|
+
throw new UserError(
|
|
603
|
+
`No agent .md files with a frontmatter \`model:\` line found in ${paths.agentsDir}.`
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
await mkdir4(paths.stacksDir, { recursive: true });
|
|
607
|
+
await atomicWriteJson(dest, { agents });
|
|
608
|
+
return { name, path: dest, agents: Object.keys(agents).length };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/version.ts
|
|
612
|
+
var VERSION = "1.0.0";
|
|
613
|
+
|
|
614
|
+
// src/plugin.ts
|
|
615
|
+
async function safeToast(client, message, variant = "success") {
|
|
616
|
+
try {
|
|
617
|
+
if (client.tui?.toast?.show) {
|
|
618
|
+
await client.tui.toast.show({ body: { message, variant } });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
}
|
|
623
|
+
await client.app?.log?.({
|
|
624
|
+
body: { service: "agent-router", level: "info", message: `[toast-fallback] ${message}` }
|
|
625
|
+
}).catch(() => {
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function ok(value) {
|
|
629
|
+
return { output: JSON.stringify(value, null, 2), metadata: value };
|
|
630
|
+
}
|
|
631
|
+
function errOut(e) {
|
|
632
|
+
const msg = e instanceof RouterError ? `${e.name}: ${e.message}` : `${e.name ?? "Error"}: ${e.message}`;
|
|
633
|
+
return { output: JSON.stringify({ error: msg }, null, 2), metadata: { error: msg } };
|
|
634
|
+
}
|
|
635
|
+
var AgentRouterPlugin = async (ctx) => {
|
|
636
|
+
const paths = await resolvePathsWithConfig();
|
|
637
|
+
const client = ctx.client;
|
|
638
|
+
await client.app?.log?.({
|
|
639
|
+
body: {
|
|
640
|
+
service: "agent-router",
|
|
641
|
+
level: "info",
|
|
642
|
+
message: "init",
|
|
643
|
+
extra: {
|
|
644
|
+
version: VERSION,
|
|
645
|
+
active: await getActiveStackName(paths).catch(() => null) ?? "(none)",
|
|
646
|
+
routerHome: paths.routerHome
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}).catch(() => {
|
|
650
|
+
});
|
|
651
|
+
return {
|
|
652
|
+
tool: {
|
|
653
|
+
router_status: tool({
|
|
654
|
+
description: "Report the active agent-router stack, the current agent \u2192 model frontmatter mapping, and all available stacks.",
|
|
655
|
+
args: {},
|
|
656
|
+
async execute() {
|
|
657
|
+
try {
|
|
658
|
+
const [active, available, current] = await Promise.all([
|
|
659
|
+
getActiveStackName(paths),
|
|
660
|
+
listStacks(paths),
|
|
661
|
+
readAgentModels(paths.agentsDir)
|
|
662
|
+
]);
|
|
663
|
+
return ok({ active, available, current });
|
|
664
|
+
} catch (e) {
|
|
665
|
+
return errOut(e);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}),
|
|
669
|
+
router_list: tool({
|
|
670
|
+
description: "List all agent-router stacks with isActive flags.",
|
|
671
|
+
args: {},
|
|
672
|
+
async execute() {
|
|
673
|
+
try {
|
|
674
|
+
const [active, available] = await Promise.all([
|
|
675
|
+
getActiveStackName(paths),
|
|
676
|
+
listStacks(paths)
|
|
677
|
+
]);
|
|
678
|
+
return ok(available.map((name) => ({ name, isActive: name === active })));
|
|
679
|
+
} catch (e) {
|
|
680
|
+
return errOut(e);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}),
|
|
684
|
+
router_use: tool({
|
|
685
|
+
description: "Apply an agent-router stack: rewrite each agent file's frontmatter model. Validates model IDs first. Restart opencode for changes to take effect.",
|
|
686
|
+
args: {
|
|
687
|
+
name: tool.schema.string().describe("Stack name to apply (see router_list)."),
|
|
688
|
+
validate: tool.schema.boolean().optional().describe("Default true. Reject the switch if any model ID is unreachable.")
|
|
689
|
+
},
|
|
690
|
+
async execute(args) {
|
|
691
|
+
try {
|
|
692
|
+
const r = await applyStack(paths, args.name, {
|
|
693
|
+
validate: args.validate ?? true
|
|
694
|
+
});
|
|
695
|
+
await safeToast(
|
|
696
|
+
client,
|
|
697
|
+
`agent-router: switched to "${r.current}". Restart opencode for change to take effect.`
|
|
698
|
+
);
|
|
699
|
+
return ok({
|
|
700
|
+
previous: r.previous,
|
|
701
|
+
current: r.current,
|
|
702
|
+
changed: r.changed,
|
|
703
|
+
restartRequired: true
|
|
704
|
+
});
|
|
705
|
+
} catch (e) {
|
|
706
|
+
return errOut(e);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}),
|
|
710
|
+
router_capture: tool({
|
|
711
|
+
description: "Snapshot the current agent \u2192 model frontmatter mapping into a named agent-router stack.",
|
|
712
|
+
args: {
|
|
713
|
+
name: tool.schema.string().describe("Name for the new stack."),
|
|
714
|
+
force: tool.schema.boolean().optional().describe("Default false. Overwrite an existing stack of the same name.")
|
|
715
|
+
},
|
|
716
|
+
async execute(args) {
|
|
717
|
+
try {
|
|
718
|
+
const r = await captureStack(paths, args.name, { force: args.force ?? false });
|
|
719
|
+
return ok({ name: r.name, path: r.path, agents: r.agents });
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return errOut(e);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}),
|
|
725
|
+
router_validate: tool({
|
|
726
|
+
description: "Validate that every model ID in a stack (or the current frontmatter) is reachable through current opencode auth.",
|
|
727
|
+
args: {
|
|
728
|
+
name: tool.schema.string().optional().describe("Stack name; omit when using `active`."),
|
|
729
|
+
active: tool.schema.boolean().optional().describe("Validate the models currently in agent frontmatter instead.")
|
|
730
|
+
},
|
|
731
|
+
async execute(args) {
|
|
732
|
+
try {
|
|
733
|
+
let stack;
|
|
734
|
+
if (args.active) {
|
|
735
|
+
const models = await readAgentModels(paths.agentsDir);
|
|
736
|
+
stack = {
|
|
737
|
+
agents: Object.fromEntries(
|
|
738
|
+
Object.entries(models).map(([k, model]) => [k, { model }])
|
|
739
|
+
)
|
|
740
|
+
};
|
|
741
|
+
} else if (args.name) {
|
|
742
|
+
stack = await readStack(paths, args.name);
|
|
743
|
+
} else {
|
|
744
|
+
return errOut(new Error("Pass `name` or `active: true`."));
|
|
745
|
+
}
|
|
746
|
+
const r = await validateStack(StackFileSchema.parse(stack));
|
|
747
|
+
return ok({
|
|
748
|
+
ok: r.ok,
|
|
749
|
+
checked: r.checked,
|
|
750
|
+
missing: r.missing.map((m) => ({ path: m.path, modelId: m.modelId }))
|
|
751
|
+
});
|
|
752
|
+
} catch (e) {
|
|
753
|
+
return errOut(e);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}),
|
|
757
|
+
router_back: tool({
|
|
758
|
+
description: "Undo the last N agent-router switches (default 1). Restart opencode for changes to take effect.",
|
|
759
|
+
args: {
|
|
760
|
+
n: tool.schema.number().optional().describe("How many switches to undo.")
|
|
761
|
+
},
|
|
762
|
+
async execute(args) {
|
|
763
|
+
try {
|
|
764
|
+
const r = await back(paths, args.n ?? 1);
|
|
765
|
+
await safeToast(
|
|
766
|
+
client,
|
|
767
|
+
`agent-router: reverted to "${r.current}". Restart opencode for change to take effect.`
|
|
768
|
+
);
|
|
769
|
+
return ok({
|
|
770
|
+
previous: r.previous,
|
|
771
|
+
current: r.current,
|
|
772
|
+
restartRequired: true
|
|
773
|
+
});
|
|
774
|
+
} catch (e) {
|
|
775
|
+
return errOut(e);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
})
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
var plugin_default = AgentRouterPlugin;
|
|
783
|
+
export {
|
|
784
|
+
AgentRouterPlugin,
|
|
785
|
+
plugin_default as default
|
|
786
|
+
};
|
|
787
|
+
//# sourceMappingURL=plugin.js.map
|