@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/tui.js
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
// src/core/config.ts
|
|
2
|
+
import { existsSync } from "fs";
|
|
3
|
+
import { readFile } from "fs/promises";
|
|
4
|
+
import { homedir as homedir2 } from "os";
|
|
5
|
+
import path2 from "path";
|
|
6
|
+
|
|
7
|
+
// src/core/errors.ts
|
|
8
|
+
var RouterError = class extends Error {
|
|
9
|
+
/** CLI exit code for this error class. Subclasses override. */
|
|
10
|
+
static exitCode = 1;
|
|
11
|
+
name = "RouterError";
|
|
12
|
+
};
|
|
13
|
+
var StackNotFoundError = class extends RouterError {
|
|
14
|
+
constructor(stackName, available) {
|
|
15
|
+
super(
|
|
16
|
+
`Stack "${stackName}" not found. Available: ${available.length ? available.join(", ") : "(none)"}.`
|
|
17
|
+
);
|
|
18
|
+
this.stackName = stackName;
|
|
19
|
+
this.available = available;
|
|
20
|
+
}
|
|
21
|
+
stackName;
|
|
22
|
+
available;
|
|
23
|
+
static exitCode = 2;
|
|
24
|
+
name = "StackNotFoundError";
|
|
25
|
+
};
|
|
26
|
+
var AgentFileError = class extends RouterError {
|
|
27
|
+
constructor(agentName, filePath, reason) {
|
|
28
|
+
super(`Agent "${agentName}" (${filePath}): ${reason}`);
|
|
29
|
+
this.agentName = agentName;
|
|
30
|
+
this.filePath = filePath;
|
|
31
|
+
}
|
|
32
|
+
agentName;
|
|
33
|
+
filePath;
|
|
34
|
+
static exitCode = 2;
|
|
35
|
+
name = "AgentFileError";
|
|
36
|
+
};
|
|
37
|
+
var ValidationError = class extends RouterError {
|
|
38
|
+
constructor(message, path8, issues) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.path = path8;
|
|
41
|
+
this.issues = issues;
|
|
42
|
+
}
|
|
43
|
+
path;
|
|
44
|
+
issues;
|
|
45
|
+
static exitCode = 4;
|
|
46
|
+
name = "ValidationError";
|
|
47
|
+
};
|
|
48
|
+
var ModelValidationError = class extends RouterError {
|
|
49
|
+
constructor(stackName, missing) {
|
|
50
|
+
super(
|
|
51
|
+
`Stack "${stackName}" references ${missing.length} unreachable model ID${missing.length === 1 ? "" : "s"}.`
|
|
52
|
+
);
|
|
53
|
+
this.stackName = stackName;
|
|
54
|
+
this.missing = missing;
|
|
55
|
+
}
|
|
56
|
+
stackName;
|
|
57
|
+
missing;
|
|
58
|
+
static exitCode = 4;
|
|
59
|
+
name = "ModelValidationError";
|
|
60
|
+
};
|
|
61
|
+
var IOError = class extends RouterError {
|
|
62
|
+
static exitCode = 3;
|
|
63
|
+
name = "IOError";
|
|
64
|
+
causedBy;
|
|
65
|
+
constructor(message, causedBy) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.causedBy = causedBy;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var UserError = class extends RouterError {
|
|
71
|
+
static exitCode = 1;
|
|
72
|
+
name = "UserError";
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// src/core/paths.ts
|
|
76
|
+
import { homedir } from "os";
|
|
77
|
+
import path from "path";
|
|
78
|
+
function resolvePaths(options = {}) {
|
|
79
|
+
const env = options.env ?? process.env;
|
|
80
|
+
const opencodeConfigDir = options.opencodeConfigDir ?? path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), ".config"), "opencode");
|
|
81
|
+
const routerHome = options.routerHome ?? env.AGENT_ROUTER_HOME ?? env.OMO_ROUTER_HOME ?? path.join(opencodeConfigDir, "agent-router");
|
|
82
|
+
const agentsDir = options.agentsDir ?? env.AGENT_ROUTER_AGENTS_DIR ?? path.join(opencodeConfigDir, "agents");
|
|
83
|
+
const stacksDir = options.stacksDir ?? env.AGENT_ROUTER_STACKS_DIR ?? path.join(routerHome, "stacks");
|
|
84
|
+
return {
|
|
85
|
+
opencodeConfigDir,
|
|
86
|
+
opencodeJsonPath: path.join(opencodeConfigDir, "opencode.json"),
|
|
87
|
+
tuiJsonPath: path.join(opencodeConfigDir, "tui.json"),
|
|
88
|
+
agentsDir,
|
|
89
|
+
opencodeBackupsDir: path.join(opencodeConfigDir, ".backups"),
|
|
90
|
+
routerHome,
|
|
91
|
+
statePath: path.join(routerHome, "state.json"),
|
|
92
|
+
stacksDir,
|
|
93
|
+
historyDir: path.join(routerHome, "history")
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/core/schema.ts
|
|
98
|
+
import { z } from "zod";
|
|
99
|
+
var StateFileSchema = z.object({
|
|
100
|
+
version: z.literal(1),
|
|
101
|
+
active: z.string().min(1),
|
|
102
|
+
previousActive: z.string().min(1).nullable(),
|
|
103
|
+
lastSwitchedAt: z.string().min(1)
|
|
104
|
+
}).strict();
|
|
105
|
+
var AgentEntrySchema = z.object({
|
|
106
|
+
model: z.string().min(1)
|
|
107
|
+
}).passthrough();
|
|
108
|
+
var StackFileSchema = z.object({
|
|
109
|
+
agents: z.record(z.string(), AgentEntrySchema)
|
|
110
|
+
}).passthrough().refine((s) => Object.keys(s.agents).length > 0, {
|
|
111
|
+
message: "Stack file must map at least one agent in `agents`."
|
|
112
|
+
});
|
|
113
|
+
var ConfigFileSchema = z.object({
|
|
114
|
+
/** Where the agent .md files live. Default: `${opencodeConfigDir}/agents`. */
|
|
115
|
+
agentsDir: z.string().min(1).optional(),
|
|
116
|
+
/** Where named stacks live. Default: `${routerHome}/stacks`. */
|
|
117
|
+
stacksDir: z.string().min(1).optional()
|
|
118
|
+
}).strict();
|
|
119
|
+
var OpencodeJsonSchema = z.object({
|
|
120
|
+
plugin: z.array(z.string()).optional()
|
|
121
|
+
}).passthrough();
|
|
122
|
+
|
|
123
|
+
// src/core/config.ts
|
|
124
|
+
var CONFIG_FILE_NAME = "config.json";
|
|
125
|
+
function expandHome(p) {
|
|
126
|
+
if (p === "~") return homedir2();
|
|
127
|
+
if (p.startsWith("~/")) return path2.join(homedir2(), p.slice(2));
|
|
128
|
+
return p;
|
|
129
|
+
}
|
|
130
|
+
async function readConfigFile(routerHome) {
|
|
131
|
+
const configPath = path2.join(routerHome, CONFIG_FILE_NAME);
|
|
132
|
+
if (!existsSync(configPath)) return null;
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = await readFile(configPath, "utf8");
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
throw new IOError(`Failed to read ${configPath}: ${cause.message}`, cause);
|
|
138
|
+
}
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(raw);
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
throw new ValidationError(
|
|
144
|
+
`agent-router config.json is not valid JSON: ${cause.message}`,
|
|
145
|
+
configPath
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const result = ConfigFileSchema.safeParse(parsed);
|
|
149
|
+
if (!result.success) {
|
|
150
|
+
throw new ValidationError(
|
|
151
|
+
`agent-router config.json failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
152
|
+
configPath,
|
|
153
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return result.data;
|
|
157
|
+
}
|
|
158
|
+
function normalizeConfigPath(routerHome, p) {
|
|
159
|
+
const expanded = expandHome(p);
|
|
160
|
+
return path2.isAbsolute(expanded) ? expanded : path2.resolve(routerHome, expanded);
|
|
161
|
+
}
|
|
162
|
+
async function resolvePathsWithConfig(options = {}) {
|
|
163
|
+
const base = resolvePaths(options);
|
|
164
|
+
const config = await readConfigFile(base.routerHome);
|
|
165
|
+
if (!config) return base;
|
|
166
|
+
const next = { ...options };
|
|
167
|
+
if (options.agentsDir === void 0 && config.agentsDir) {
|
|
168
|
+
next.agentsDir = normalizeConfigPath(
|
|
169
|
+
base.routerHome,
|
|
170
|
+
config.agentsDir
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (options.stacksDir === void 0 && config.stacksDir) {
|
|
174
|
+
next.stacksDir = normalizeConfigPath(
|
|
175
|
+
base.routerHome,
|
|
176
|
+
config.stacksDir
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return resolvePaths(next);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/core/atomic-write.ts
|
|
183
|
+
import { randomBytes } from "crypto";
|
|
184
|
+
import { mkdir, realpath, rename, unlink, writeFile } from "fs/promises";
|
|
185
|
+
import path3 from "path";
|
|
186
|
+
async function resolveDestination(destPath) {
|
|
187
|
+
try {
|
|
188
|
+
return await realpath(destPath);
|
|
189
|
+
} catch {
|
|
190
|
+
try {
|
|
191
|
+
const dir = await realpath(path3.dirname(destPath));
|
|
192
|
+
return path3.join(dir, path3.basename(destPath));
|
|
193
|
+
} catch {
|
|
194
|
+
return destPath;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function atomicWriteFile(destPath, contents) {
|
|
199
|
+
await mkdir(path3.dirname(destPath), { recursive: true });
|
|
200
|
+
const dest = await resolveDestination(destPath);
|
|
201
|
+
const dir = path3.dirname(dest);
|
|
202
|
+
const tmp = path3.join(
|
|
203
|
+
dir,
|
|
204
|
+
`.${path3.basename(dest)}.artmp-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
205
|
+
);
|
|
206
|
+
try {
|
|
207
|
+
await writeFile(tmp, contents, { encoding: "utf8", mode: 420 });
|
|
208
|
+
await rename(tmp, dest);
|
|
209
|
+
} catch (cause) {
|
|
210
|
+
await unlink(tmp).catch(() => {
|
|
211
|
+
});
|
|
212
|
+
throw new IOError(`Atomic write failed for ${destPath}: ${cause.message}`, cause);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function atomicWriteJson(destPath, value) {
|
|
216
|
+
const text2 = `${JSON.stringify(value, null, 2)}
|
|
217
|
+
`;
|
|
218
|
+
await atomicWriteFile(destPath, text2);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/core/stack-manager.ts
|
|
222
|
+
import { existsSync as existsSync5 } from "fs";
|
|
223
|
+
import { copyFile as copyFile2, mkdir as mkdir4, readFile as readFile4, readdir as readdir3, unlink as unlink3 } from "fs/promises";
|
|
224
|
+
import path7 from "path";
|
|
225
|
+
|
|
226
|
+
// src/core/frontmatter.ts
|
|
227
|
+
import { existsSync as existsSync2 } from "fs";
|
|
228
|
+
import { readFile as readFile2, readdir } from "fs/promises";
|
|
229
|
+
import path4 from "path";
|
|
230
|
+
var FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
231
|
+
var MODEL_LINE_RE = /^model:[ \t]*(.*)$/m;
|
|
232
|
+
function cleanModelValue(raw) {
|
|
233
|
+
let v = raw;
|
|
234
|
+
const hash = v.search(/[ \t]#/);
|
|
235
|
+
if (hash >= 0) v = v.slice(0, hash);
|
|
236
|
+
v = v.trim();
|
|
237
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2 || v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
238
|
+
v = v.slice(1, -1);
|
|
239
|
+
}
|
|
240
|
+
return v;
|
|
241
|
+
}
|
|
242
|
+
function getFrontmatterModel(content) {
|
|
243
|
+
const fm = FRONTMATTER_RE.exec(content);
|
|
244
|
+
if (!fm?.[1]) return null;
|
|
245
|
+
const line = MODEL_LINE_RE.exec(fm[1]);
|
|
246
|
+
if (!line) return null;
|
|
247
|
+
const value = cleanModelValue(line[1] ?? "");
|
|
248
|
+
return value.length > 0 ? value : null;
|
|
249
|
+
}
|
|
250
|
+
function setFrontmatterModel(content, model) {
|
|
251
|
+
const fm = FRONTMATTER_RE.exec(content);
|
|
252
|
+
if (!fm?.[1]) throw new Error("no frontmatter block");
|
|
253
|
+
const block = fm[1];
|
|
254
|
+
if (!MODEL_LINE_RE.test(block)) throw new Error("no `model:` line in frontmatter");
|
|
255
|
+
const nextBlock = block.replace(MODEL_LINE_RE, () => `model: ${model}`);
|
|
256
|
+
const blockStart = fm[0].indexOf(block);
|
|
257
|
+
const nextFm = fm[0].slice(0, blockStart) + nextBlock + fm[0].slice(blockStart + block.length);
|
|
258
|
+
return nextFm + content.slice(fm[0].length);
|
|
259
|
+
}
|
|
260
|
+
function agentFilePath(agentsDir, name) {
|
|
261
|
+
return path4.join(agentsDir, `${name}.md`);
|
|
262
|
+
}
|
|
263
|
+
async function listAgentFiles(agentsDir) {
|
|
264
|
+
if (!existsSync2(agentsDir)) return [];
|
|
265
|
+
let names;
|
|
266
|
+
try {
|
|
267
|
+
names = await readdir(agentsDir);
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
throw new IOError(`Failed to read agents dir: ${cause.message}`, cause);
|
|
270
|
+
}
|
|
271
|
+
return names.filter((n) => n.endsWith(".md")).map((n) => n.slice(0, -".md".length)).sort();
|
|
272
|
+
}
|
|
273
|
+
async function readAgentModels(agentsDir) {
|
|
274
|
+
const out = {};
|
|
275
|
+
for (const name of await listAgentFiles(agentsDir)) {
|
|
276
|
+
const filePath = agentFilePath(agentsDir, name);
|
|
277
|
+
let content;
|
|
278
|
+
try {
|
|
279
|
+
content = await readFile2(filePath, "utf8");
|
|
280
|
+
} catch (cause) {
|
|
281
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
282
|
+
}
|
|
283
|
+
const model = getFrontmatterModel(content);
|
|
284
|
+
if (model !== null) out[name] = model;
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
async function readAgentFileStrict(agentsDir, name) {
|
|
289
|
+
const filePath = agentFilePath(agentsDir, name);
|
|
290
|
+
if (!existsSync2(filePath)) {
|
|
291
|
+
throw new AgentFileError(name, filePath, "file does not exist");
|
|
292
|
+
}
|
|
293
|
+
let content;
|
|
294
|
+
try {
|
|
295
|
+
content = await readFile2(filePath, "utf8");
|
|
296
|
+
} catch (cause) {
|
|
297
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
298
|
+
}
|
|
299
|
+
const model = getFrontmatterModel(content);
|
|
300
|
+
if (model === null) {
|
|
301
|
+
throw new AgentFileError(name, filePath, "no frontmatter `model:` line to rewrite");
|
|
302
|
+
}
|
|
303
|
+
return { filePath, content, model };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// src/core/history.ts
|
|
307
|
+
import { mkdir as mkdir3, readdir as readdir2, unlink as unlink2 } from "fs/promises";
|
|
308
|
+
import path6 from "path";
|
|
309
|
+
|
|
310
|
+
// src/core/backups.ts
|
|
311
|
+
import { existsSync as existsSync3 } from "fs";
|
|
312
|
+
import { copyFile, mkdir as mkdir2 } from "fs/promises";
|
|
313
|
+
import path5 from "path";
|
|
314
|
+
function timestampStamp(date = /* @__PURE__ */ new Date()) {
|
|
315
|
+
return date.toISOString().replace(/[:.]/g, "-");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/core/history.ts
|
|
319
|
+
function filename(fromStack, toStack, when = /* @__PURE__ */ new Date()) {
|
|
320
|
+
const stamp = timestampStamp(when);
|
|
321
|
+
const safe = (s) => s.replace(/[^A-Za-z0-9._()-]/g, "_");
|
|
322
|
+
return `${stamp}__${safe(fromStack)}-to-${safe(toStack)}.json`;
|
|
323
|
+
}
|
|
324
|
+
function parseHistoryFilename(name) {
|
|
325
|
+
if (!name.endsWith(".json")) return null;
|
|
326
|
+
const stem = name.slice(0, -".json".length);
|
|
327
|
+
const sep = stem.indexOf("__");
|
|
328
|
+
if (sep < 0) return null;
|
|
329
|
+
const stamp = stem.slice(0, sep);
|
|
330
|
+
const rest = stem.slice(sep + 2);
|
|
331
|
+
const toIdx = rest.lastIndexOf("-to-");
|
|
332
|
+
if (toIdx < 0) return null;
|
|
333
|
+
return {
|
|
334
|
+
id: stem,
|
|
335
|
+
timestamp: stamp,
|
|
336
|
+
fromStack: rest.slice(0, toIdx),
|
|
337
|
+
toStack: rest.slice(toIdx + "-to-".length)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
async function appendHistory(historyDir, fromStack, toStack, displacedContent) {
|
|
341
|
+
await mkdir3(historyDir, { recursive: true });
|
|
342
|
+
const name = filename(fromStack, toStack);
|
|
343
|
+
await atomicWriteFile(path6.join(historyDir, name), displacedContent);
|
|
344
|
+
return name.slice(0, -".json".length);
|
|
345
|
+
}
|
|
346
|
+
async function listHistory(historyDir) {
|
|
347
|
+
let names;
|
|
348
|
+
try {
|
|
349
|
+
names = await readdir2(historyDir);
|
|
350
|
+
} catch (cause) {
|
|
351
|
+
if (cause.code === "ENOENT") return [];
|
|
352
|
+
throw new IOError(`Failed to read history dir: ${cause.message}`, cause);
|
|
353
|
+
}
|
|
354
|
+
const entries = [];
|
|
355
|
+
for (const n of names) {
|
|
356
|
+
const parsed = parseHistoryFilename(n);
|
|
357
|
+
if (parsed) entries.push({ ...parsed, path: path6.join(historyDir, n) });
|
|
358
|
+
}
|
|
359
|
+
entries.sort((a, b) => a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0);
|
|
360
|
+
return entries;
|
|
361
|
+
}
|
|
362
|
+
async function trimHistory(historyDir, keep = 20) {
|
|
363
|
+
const all = await listHistory(historyDir);
|
|
364
|
+
if (all.length <= keep) return [];
|
|
365
|
+
const toDelete = all.slice(keep);
|
|
366
|
+
const deleted = [];
|
|
367
|
+
for (const e of toDelete) {
|
|
368
|
+
try {
|
|
369
|
+
await unlink2(e.path);
|
|
370
|
+
deleted.push(e.id);
|
|
371
|
+
} catch (cause) {
|
|
372
|
+
void cause;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return deleted;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/core/state.ts
|
|
379
|
+
import { existsSync as existsSync4 } from "fs";
|
|
380
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
381
|
+
async function readState(statePath) {
|
|
382
|
+
if (!existsSync4(statePath)) return null;
|
|
383
|
+
let raw;
|
|
384
|
+
try {
|
|
385
|
+
raw = await readFile3(statePath, "utf8");
|
|
386
|
+
} catch (cause) {
|
|
387
|
+
throw new IOError(`Failed to read ${statePath}: ${cause.message}`, cause);
|
|
388
|
+
}
|
|
389
|
+
let parsed;
|
|
390
|
+
try {
|
|
391
|
+
parsed = JSON.parse(raw);
|
|
392
|
+
} catch (cause) {
|
|
393
|
+
throw new ValidationError(
|
|
394
|
+
`state.json is not valid JSON: ${cause.message}`,
|
|
395
|
+
statePath
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
const result = StateFileSchema.safeParse(parsed);
|
|
399
|
+
if (!result.success) {
|
|
400
|
+
throw new ValidationError(
|
|
401
|
+
`state.json failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
402
|
+
statePath,
|
|
403
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return result.data;
|
|
407
|
+
}
|
|
408
|
+
async function writeState(statePath, state) {
|
|
409
|
+
const result = StateFileSchema.safeParse(state);
|
|
410
|
+
if (!result.success) {
|
|
411
|
+
throw new ValidationError(
|
|
412
|
+
`Refusing to write invalid state.json: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
413
|
+
statePath
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
await atomicWriteJson(statePath, result.data);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// src/core/validator.ts
|
|
420
|
+
import { execFile } from "child_process";
|
|
421
|
+
import { promisify } from "util";
|
|
422
|
+
var execFileAsync = promisify(execFile);
|
|
423
|
+
var DEFAULT_RUNNER = async () => {
|
|
424
|
+
const { stdout } = await execFileAsync("opencode", ["models"], {
|
|
425
|
+
encoding: "utf8",
|
|
426
|
+
timeout: 3e4,
|
|
427
|
+
maxBuffer: 4 * 1024 * 1024
|
|
428
|
+
});
|
|
429
|
+
return stdout;
|
|
430
|
+
};
|
|
431
|
+
function parseModelList(stdout) {
|
|
432
|
+
const out = /* @__PURE__ */ new Set();
|
|
433
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
434
|
+
const line = raw.trim();
|
|
435
|
+
if (!line) continue;
|
|
436
|
+
if (line.startsWith("#")) continue;
|
|
437
|
+
out.add(line);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
function collectModelRefs(stack) {
|
|
442
|
+
const refs = [];
|
|
443
|
+
for (const [k, v] of Object.entries(stack.agents)) {
|
|
444
|
+
refs.push({ path: `agents.${k}.model`, modelId: v.model });
|
|
445
|
+
}
|
|
446
|
+
return refs;
|
|
447
|
+
}
|
|
448
|
+
async function validateStack(stack, options = {}) {
|
|
449
|
+
const runner = options.runOpencodeModels ?? DEFAULT_RUNNER;
|
|
450
|
+
const stdout = await runner();
|
|
451
|
+
const available = parseModelList(stdout);
|
|
452
|
+
const refs = collectModelRefs(stack);
|
|
453
|
+
const missing = refs.filter((r) => !available.has(r.modelId));
|
|
454
|
+
return { ok: missing.length === 0, checked: refs.length, missing, available };
|
|
455
|
+
}
|
|
456
|
+
async function validateStackOrThrow(stackName, stack, options = {}) {
|
|
457
|
+
const result = await validateStack(stack, options);
|
|
458
|
+
if (!result.ok) throw new ModelValidationError(stackName, result.missing);
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/core/stack-manager.ts
|
|
463
|
+
async function listStacks(paths) {
|
|
464
|
+
if (!existsSync5(paths.stacksDir)) return [];
|
|
465
|
+
let names;
|
|
466
|
+
try {
|
|
467
|
+
names = await readdir3(paths.stacksDir);
|
|
468
|
+
} catch (cause) {
|
|
469
|
+
throw new IOError(`Failed to read stacks dir: ${cause.message}`, cause);
|
|
470
|
+
}
|
|
471
|
+
return names.filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -".json".length)).sort();
|
|
472
|
+
}
|
|
473
|
+
function stackPath(paths, name) {
|
|
474
|
+
return path7.join(paths.stacksDir, `${name}.json`);
|
|
475
|
+
}
|
|
476
|
+
async function readStack(paths, name) {
|
|
477
|
+
const filePath = stackPath(paths, name);
|
|
478
|
+
if (!existsSync5(filePath)) {
|
|
479
|
+
throw new StackNotFoundError(name, await listStacks(paths));
|
|
480
|
+
}
|
|
481
|
+
const raw = await readFile4(filePath, "utf8").catch((cause) => {
|
|
482
|
+
throw new IOError(`Failed to read ${filePath}: ${cause.message}`, cause);
|
|
483
|
+
});
|
|
484
|
+
let parsed;
|
|
485
|
+
try {
|
|
486
|
+
parsed = JSON.parse(raw);
|
|
487
|
+
} catch (cause) {
|
|
488
|
+
throw new ValidationError(
|
|
489
|
+
`Stack "${name}" is not valid JSON: ${cause.message}`,
|
|
490
|
+
filePath
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
const result = StackFileSchema.safeParse(parsed);
|
|
494
|
+
if (!result.success) {
|
|
495
|
+
throw new ValidationError(
|
|
496
|
+
`Stack "${name}" failed validation: ${result.error.issues.map((i) => i.message).join("; ")}`,
|
|
497
|
+
filePath,
|
|
498
|
+
result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
return result.data;
|
|
502
|
+
}
|
|
503
|
+
async function getActiveStackName(paths) {
|
|
504
|
+
const state = await readState(paths.statePath);
|
|
505
|
+
return state?.active ?? null;
|
|
506
|
+
}
|
|
507
|
+
async function applyStack(paths, name, options = {}) {
|
|
508
|
+
const validate = options.validate ?? true;
|
|
509
|
+
const forceInvalid = options.forceInvalid ?? false;
|
|
510
|
+
const target = await readStack(paths, name);
|
|
511
|
+
if (validate && !forceInvalid) {
|
|
512
|
+
await validateStackOrThrow(name, target, options.validateOptions);
|
|
513
|
+
}
|
|
514
|
+
const pending = [];
|
|
515
|
+
for (const [agent, entry] of Object.entries(target.agents)) {
|
|
516
|
+
const { filePath, content, model } = await readAgentFileStrict(paths.agentsDir, agent);
|
|
517
|
+
pending.push({
|
|
518
|
+
agent,
|
|
519
|
+
filePath,
|
|
520
|
+
next: model === entry.model ? null : setFrontmatterModel(content, entry.model)
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const prevState = await readState(paths.statePath);
|
|
524
|
+
const prevActive = prevState?.active ?? null;
|
|
525
|
+
const displaced = { agents: modelsToStackAgents(await readAgentModels(paths.agentsDir)) };
|
|
526
|
+
const historyId = await appendHistory(
|
|
527
|
+
paths.historyDir,
|
|
528
|
+
prevActive ?? "(none)",
|
|
529
|
+
name,
|
|
530
|
+
`${JSON.stringify(displaced, null, 2)}
|
|
531
|
+
`
|
|
532
|
+
);
|
|
533
|
+
const changed = [];
|
|
534
|
+
for (const p of pending) {
|
|
535
|
+
if (p.next === null) continue;
|
|
536
|
+
await atomicWriteFile(p.filePath, p.next);
|
|
537
|
+
changed.push(p.agent);
|
|
538
|
+
}
|
|
539
|
+
await writeState(paths.statePath, {
|
|
540
|
+
version: 1,
|
|
541
|
+
active: name,
|
|
542
|
+
previousActive: prevActive,
|
|
543
|
+
lastSwitchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
544
|
+
});
|
|
545
|
+
await trimHistory(paths.historyDir).catch(() => {
|
|
546
|
+
});
|
|
547
|
+
return {
|
|
548
|
+
previous: prevActive,
|
|
549
|
+
current: name,
|
|
550
|
+
changed,
|
|
551
|
+
historyId,
|
|
552
|
+
restartRequired: true
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
function modelsToStackAgents(models) {
|
|
556
|
+
const out = {};
|
|
557
|
+
for (const k of Object.keys(models).sort()) {
|
|
558
|
+
const model = models[k];
|
|
559
|
+
if (model !== void 0) out[k] = { model };
|
|
560
|
+
}
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
async function back(paths, n = 1, options = {}) {
|
|
564
|
+
if (n < 1) throw new UserError("`back -n` must be at least 1.");
|
|
565
|
+
const state = await readState(paths.statePath);
|
|
566
|
+
if (!state || !state.previousActive) {
|
|
567
|
+
throw new UserError("No previous stack to revert to.");
|
|
568
|
+
}
|
|
569
|
+
if (n === 1) return applyStack(paths, state.previousActive, options);
|
|
570
|
+
const entries = await listHistory(paths.historyDir);
|
|
571
|
+
if (entries.length < n) {
|
|
572
|
+
throw new UserError(
|
|
573
|
+
`Cannot go back ${n} steps; only ${entries.length} switch${entries.length === 1 ? "" : "es"} in history.`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const target = entries[n - 1]?.fromStack;
|
|
577
|
+
if (!target || target === "(none)") {
|
|
578
|
+
throw new UserError(`Cannot go back ${n} steps to a non-stack target ("${target}").`);
|
|
579
|
+
}
|
|
580
|
+
return applyStack(paths, target, options);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/tui/actions.ts
|
|
584
|
+
function targetLabel(target) {
|
|
585
|
+
return target.agent;
|
|
586
|
+
}
|
|
587
|
+
function listModelTargets(stack) {
|
|
588
|
+
return Object.entries(stack.agents).map(([agent, entry]) => ({
|
|
589
|
+
agent,
|
|
590
|
+
model: entry.model
|
|
591
|
+
}));
|
|
592
|
+
}
|
|
593
|
+
function applyModelEdit(stack, agent, model) {
|
|
594
|
+
const entry = stack.agents[agent];
|
|
595
|
+
if (!entry) {
|
|
596
|
+
throw new Error(`No entry "${agent}" in stack`);
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
...stack,
|
|
600
|
+
agents: {
|
|
601
|
+
...stack.agents,
|
|
602
|
+
[agent]: { ...entry, model }
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function collectHostModels(providers) {
|
|
607
|
+
if (!Array.isArray(providers)) return [];
|
|
608
|
+
const out = /* @__PURE__ */ new Set();
|
|
609
|
+
for (const provider of providers) {
|
|
610
|
+
if (typeof provider !== "object" || provider === null) continue;
|
|
611
|
+
const record = provider;
|
|
612
|
+
const id = typeof record.id === "string" ? record.id : void 0;
|
|
613
|
+
if (!id) continue;
|
|
614
|
+
const models = record.models;
|
|
615
|
+
if (Array.isArray(models)) {
|
|
616
|
+
for (const m of models) {
|
|
617
|
+
if (typeof m === "string") out.add(`${id}/${m}`);
|
|
618
|
+
else if (typeof m === "object" && m !== null) {
|
|
619
|
+
const mid = m.id;
|
|
620
|
+
if (typeof mid === "string") out.add(`${id}/${mid}`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} else if (typeof models === "object" && models !== null) {
|
|
624
|
+
for (const key of Object.keys(models)) out.add(`${id}/${key}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return [...out].sort();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/tui/dialogs.ts
|
|
631
|
+
function canOpenDialogs(api) {
|
|
632
|
+
return Boolean(api.ui.DialogSelect && api.ui.dialog);
|
|
633
|
+
}
|
|
634
|
+
function toastError(api, e) {
|
|
635
|
+
api.ui.toast({
|
|
636
|
+
title: "agent-router",
|
|
637
|
+
message: e.message ?? String(e),
|
|
638
|
+
variant: "error"
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
function openSelect(api, props) {
|
|
642
|
+
const { DialogSelect, dialog } = api.ui;
|
|
643
|
+
if (!DialogSelect || !dialog) return;
|
|
644
|
+
dialog.replace(() => DialogSelect(props));
|
|
645
|
+
}
|
|
646
|
+
async function pickStack(deps, title, onPick) {
|
|
647
|
+
const [stacks, active] = await Promise.all([
|
|
648
|
+
listStacks(deps.paths),
|
|
649
|
+
getActiveStackName(deps.paths)
|
|
650
|
+
]);
|
|
651
|
+
if (stacks.length === 0) {
|
|
652
|
+
deps.api.ui.toast({
|
|
653
|
+
title: "agent-router",
|
|
654
|
+
message: "No stacks found \u2014 run `agent-router init` first.",
|
|
655
|
+
variant: "warning"
|
|
656
|
+
});
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
const options = stacks.map((name) => ({
|
|
660
|
+
title: name,
|
|
661
|
+
value: name,
|
|
662
|
+
description: name === active ? "\u25CF active" : void 0,
|
|
663
|
+
onSelect: () => onPick(name, active)
|
|
664
|
+
}));
|
|
665
|
+
openSelect(deps.api, { title, options, current: active ?? void 0 });
|
|
666
|
+
}
|
|
667
|
+
function openStackSwitcher(deps) {
|
|
668
|
+
void pickStack(deps, "Switch stack", (name, active) => {
|
|
669
|
+
deps.api.ui.dialog?.clear();
|
|
670
|
+
if (name === active) {
|
|
671
|
+
deps.api.ui.toast({ title: "agent-router", message: `"${name}" is already active` });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
deps.api.ui.toast({ title: "agent-router", message: `validating & switching to "${name}"\u2026` });
|
|
675
|
+
applyStack(deps.paths, name, { validate: true }).then((r) => {
|
|
676
|
+
deps.refresh();
|
|
677
|
+
deps.api.ui.toast({
|
|
678
|
+
title: "agent-router",
|
|
679
|
+
message: `switched to "${r.current}" \u2014 restart opencode to apply`,
|
|
680
|
+
variant: "success"
|
|
681
|
+
});
|
|
682
|
+
}).catch((e) => toastError(deps.api, e));
|
|
683
|
+
}).catch((e) => toastError(deps.api, e));
|
|
684
|
+
}
|
|
685
|
+
function openStackViewer(deps) {
|
|
686
|
+
void pickStack(deps, "View stack", (name) => {
|
|
687
|
+
readStack(deps.paths, name).then((stack) => {
|
|
688
|
+
const rows = listModelTargets(stack);
|
|
689
|
+
const options = rows.map((row) => ({
|
|
690
|
+
title: targetLabel(row),
|
|
691
|
+
value: targetLabel(row),
|
|
692
|
+
description: row.model,
|
|
693
|
+
onSelect: () => deps.api.ui.dialog?.clear()
|
|
694
|
+
}));
|
|
695
|
+
openSelect(deps.api, { title: `stack: ${name}`, options });
|
|
696
|
+
}).catch((e) => toastError(deps.api, e));
|
|
697
|
+
}).catch((e) => toastError(deps.api, e));
|
|
698
|
+
}
|
|
699
|
+
function openStackEditor(deps) {
|
|
700
|
+
void pickStack(deps, "Edit stack", (name) => {
|
|
701
|
+
readStack(deps.paths, name).then((stack) => {
|
|
702
|
+
const rows = listModelTargets(stack);
|
|
703
|
+
const options = rows.map((row) => ({
|
|
704
|
+
title: targetLabel(row),
|
|
705
|
+
value: targetLabel(row),
|
|
706
|
+
description: row.model,
|
|
707
|
+
onSelect: () => openModelPicker(deps, name, row)
|
|
708
|
+
}));
|
|
709
|
+
openSelect(deps.api, { title: `edit ${name}: pick agent`, options });
|
|
710
|
+
}).catch((e) => toastError(deps.api, e));
|
|
711
|
+
}).catch((e) => toastError(deps.api, e));
|
|
712
|
+
}
|
|
713
|
+
function openModelPicker(deps, stackName, row) {
|
|
714
|
+
const models = collectHostModels(deps.api.state?.provider);
|
|
715
|
+
if (models.length === 0) {
|
|
716
|
+
deps.api.ui.dialog?.clear();
|
|
717
|
+
deps.api.ui.toast({
|
|
718
|
+
title: "agent-router",
|
|
719
|
+
message: "Model catalog unavailable \u2014 edit the stack file via `agent-router edit` instead.",
|
|
720
|
+
variant: "warning"
|
|
721
|
+
});
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const options = models.map((id) => ({
|
|
725
|
+
title: id,
|
|
726
|
+
value: id,
|
|
727
|
+
onSelect: () => {
|
|
728
|
+
deps.api.ui.dialog?.clear();
|
|
729
|
+
saveModelEdit(deps, stackName, row, id).catch((e) => toastError(deps.api, e));
|
|
730
|
+
}
|
|
731
|
+
}));
|
|
732
|
+
openSelect(deps.api, {
|
|
733
|
+
title: `edit ${stackName}: ${targetLabel(row)}`,
|
|
734
|
+
options,
|
|
735
|
+
current: row.model
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
async function saveModelEdit(deps, stackName, row, model) {
|
|
739
|
+
const stack = await readStack(deps.paths, stackName);
|
|
740
|
+
const edited = StackFileSchema.parse(applyModelEdit(stack, row.agent, model));
|
|
741
|
+
await atomicWriteJson(stackPath(deps.paths, stackName), edited);
|
|
742
|
+
deps.refresh();
|
|
743
|
+
const active = await getActiveStackName(deps.paths);
|
|
744
|
+
const hint = active === stackName ? ` \u2014 run \`agent-router use ${stackName}\` + restart to apply to the agent files` : "";
|
|
745
|
+
deps.api.ui.toast({
|
|
746
|
+
title: "agent-router",
|
|
747
|
+
message: `${stackName}: ${targetLabel(row)} \u2192 ${model}${hint}`,
|
|
748
|
+
variant: "success"
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
function openBackConfirm(deps) {
|
|
752
|
+
readState(deps.paths.statePath).then((state) => {
|
|
753
|
+
if (!state?.previousActive) {
|
|
754
|
+
deps.api.ui.toast({ title: "agent-router", message: "No previous stack to revert to." });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const { DialogConfirm, dialog } = deps.api.ui;
|
|
758
|
+
const revert = () => {
|
|
759
|
+
dialog?.clear();
|
|
760
|
+
back(deps.paths, 1).then((r) => {
|
|
761
|
+
deps.refresh();
|
|
762
|
+
deps.api.ui.toast({
|
|
763
|
+
title: "agent-router",
|
|
764
|
+
message: `reverted to "${r.current}" \u2014 restart opencode to apply`,
|
|
765
|
+
variant: "success"
|
|
766
|
+
});
|
|
767
|
+
}).catch((e) => toastError(deps.api, e));
|
|
768
|
+
};
|
|
769
|
+
if (!DialogConfirm || !dialog) {
|
|
770
|
+
revert();
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
dialog.replace(
|
|
774
|
+
() => DialogConfirm({
|
|
775
|
+
title: "agent-router",
|
|
776
|
+
message: `Revert to "${state.previousActive}"?`,
|
|
777
|
+
onConfirm: revert,
|
|
778
|
+
onCancel: () => dialog.clear()
|
|
779
|
+
})
|
|
780
|
+
);
|
|
781
|
+
}).catch((e) => toastError(deps.api, e));
|
|
782
|
+
}
|
|
783
|
+
function openValidator(deps) {
|
|
784
|
+
void pickStack(deps, "Validate stack", (name) => {
|
|
785
|
+
deps.api.ui.dialog?.clear();
|
|
786
|
+
deps.api.ui.toast({ title: "agent-router", message: `validating "${name}"\u2026` });
|
|
787
|
+
readStack(deps.paths, name).then((stack) => validateStack(stack)).then((result) => {
|
|
788
|
+
if (result.ok) {
|
|
789
|
+
deps.api.ui.toast({
|
|
790
|
+
title: "agent-router",
|
|
791
|
+
message: `"${name}" ok \u2014 ${result.checked} model refs reachable`,
|
|
792
|
+
variant: "success"
|
|
793
|
+
});
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const sample = result.missing.slice(0, 3).map((m) => m.modelId).join(", ");
|
|
797
|
+
const extra = result.missing.length > 3 ? ` (+${result.missing.length - 3} more)` : "";
|
|
798
|
+
deps.api.ui.toast({
|
|
799
|
+
title: "agent-router",
|
|
800
|
+
message: `"${name}": ${result.missing.length} unreachable \u2014 ${sample}${extra}`,
|
|
801
|
+
variant: "warning"
|
|
802
|
+
});
|
|
803
|
+
}).catch((e) => toastError(deps.api, e));
|
|
804
|
+
}).catch((e) => toastError(deps.api, e));
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/tui/render.ts
|
|
808
|
+
function materialize(nodes, solid) {
|
|
809
|
+
const root = solid.createElement("box");
|
|
810
|
+
solid.setProp(root, "flexDirection", "column");
|
|
811
|
+
for (const node of nodes) {
|
|
812
|
+
solid.insert(root, materializeNode(node, solid));
|
|
813
|
+
}
|
|
814
|
+
return root;
|
|
815
|
+
}
|
|
816
|
+
function materializeNode(node, solid) {
|
|
817
|
+
const element = solid.createElement(node.kind);
|
|
818
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
819
|
+
if (value !== void 0) solid.setProp(element, name, value);
|
|
820
|
+
}
|
|
821
|
+
if (node.kind === "text") {
|
|
822
|
+
solid.insert(element, node.text ?? "");
|
|
823
|
+
}
|
|
824
|
+
for (const child of node.children ?? []) {
|
|
825
|
+
solid.insert(element, materializeNode(child, solid));
|
|
826
|
+
}
|
|
827
|
+
return element;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/tui/store.ts
|
|
831
|
+
function snapshotKey(active, stacks) {
|
|
832
|
+
return `${active ?? "\0"}|${stacks.join(",")}`;
|
|
833
|
+
}
|
|
834
|
+
async function readStackSnapshot(paths) {
|
|
835
|
+
const [active, stacks] = await Promise.all([
|
|
836
|
+
getActiveStackName(paths).catch(() => null),
|
|
837
|
+
listStacks(paths).catch(() => [])
|
|
838
|
+
]);
|
|
839
|
+
return { active, stacks, key: snapshotKey(active, stacks) };
|
|
840
|
+
}
|
|
841
|
+
function createSidebarPoller(options) {
|
|
842
|
+
const schedule = options.schedule ?? ((fn, ms) => setTimeout(fn, ms));
|
|
843
|
+
const cancel = options.cancel ?? ((handle) => clearTimeout(handle));
|
|
844
|
+
let current = options.initial;
|
|
845
|
+
let disposed = false;
|
|
846
|
+
let inFlight = false;
|
|
847
|
+
let timer;
|
|
848
|
+
const tick = async () => {
|
|
849
|
+
if (disposed) return;
|
|
850
|
+
if (inFlight) {
|
|
851
|
+
timer = schedule(tick, options.intervalMs);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
inFlight = true;
|
|
855
|
+
try {
|
|
856
|
+
const next = await options.read();
|
|
857
|
+
if (!disposed && next.key !== current.key) {
|
|
858
|
+
const prev = current;
|
|
859
|
+
current = next;
|
|
860
|
+
options.onChange(next, prev);
|
|
861
|
+
}
|
|
862
|
+
} catch {
|
|
863
|
+
} finally {
|
|
864
|
+
inFlight = false;
|
|
865
|
+
if (!disposed) timer = schedule(tick, options.intervalMs);
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
timer = schedule(tick, options.intervalMs);
|
|
869
|
+
return () => {
|
|
870
|
+
disposed = true;
|
|
871
|
+
if (timer !== void 0) cancel(timer);
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/tui/view.ts
|
|
876
|
+
function text(content, props = {}) {
|
|
877
|
+
return { kind: "text", props, text: content };
|
|
878
|
+
}
|
|
879
|
+
function restartRequired(snapshot, ctx) {
|
|
880
|
+
return snapshot.active !== ctx.bootActive;
|
|
881
|
+
}
|
|
882
|
+
function buildSidebarNodes(snapshot, ctx) {
|
|
883
|
+
const theme = ctx.theme ?? {};
|
|
884
|
+
const nodes = [
|
|
885
|
+
text("agent-router", { fg: theme.textMuted }),
|
|
886
|
+
text(` \u25A3 ${snapshot.active ?? "(none)"}`, { fg: theme.success })
|
|
887
|
+
];
|
|
888
|
+
if (restartRequired(snapshot, ctx)) {
|
|
889
|
+
nodes.push(text(" \u27F3 restart required", { fg: theme.warning }));
|
|
890
|
+
}
|
|
891
|
+
nodes.push(
|
|
892
|
+
text(` ${snapshot.stacks.length} stack${snapshot.stacks.length === 1 ? "" : "s"}`, {
|
|
893
|
+
fg: theme.textMuted
|
|
894
|
+
})
|
|
895
|
+
);
|
|
896
|
+
return nodes;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// src/tui/index.ts
|
|
900
|
+
var POLL_INTERVAL_MS = 1500;
|
|
901
|
+
var SIDEBAR_ORDER = 850;
|
|
902
|
+
function debugLog(message) {
|
|
903
|
+
const target = process.env.AGENT_ROUTER_TUI_DEBUG ?? process.env.OMO_TUI_DEBUG;
|
|
904
|
+
if (!target) return;
|
|
905
|
+
import("fs").then((fs) => fs.appendFileSync(target, `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
|
|
906
|
+
`)).catch(() => {
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
async function importHostSolid() {
|
|
910
|
+
try {
|
|
911
|
+
const mod = await import("@opentui/solid");
|
|
912
|
+
if (typeof mod.createElement !== "function") {
|
|
913
|
+
debugLog("solid module lacks createElement \u2014 treating as unavailable");
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
return mod;
|
|
917
|
+
} catch (e) {
|
|
918
|
+
debugLog(`solid import FAILED: ${e.message}`);
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
function buildCommands(api, deps, status) {
|
|
923
|
+
const commands = [
|
|
924
|
+
{
|
|
925
|
+
title: "agent-router: status",
|
|
926
|
+
value: "agent-router.status",
|
|
927
|
+
description: "Show the active agent-router stack",
|
|
928
|
+
category: "agent-router",
|
|
929
|
+
slash: { name: "agent-status" },
|
|
930
|
+
onSelect: status
|
|
931
|
+
}
|
|
932
|
+
];
|
|
933
|
+
if (!canOpenDialogs(api)) return commands;
|
|
934
|
+
commands.push(
|
|
935
|
+
{
|
|
936
|
+
title: "agent-router: switch stack",
|
|
937
|
+
value: "agent-router.switch",
|
|
938
|
+
description: "Apply a different model stack to your agents",
|
|
939
|
+
category: "agent-router",
|
|
940
|
+
slash: { name: "agent-switch", aliases: ["ar"] },
|
|
941
|
+
onSelect: () => openStackSwitcher(deps)
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
title: "agent-router: view stack",
|
|
945
|
+
value: "agent-router.view",
|
|
946
|
+
description: "Inspect a stack's agent \u2192 model assignments",
|
|
947
|
+
category: "agent-router",
|
|
948
|
+
slash: { name: "agent-view" },
|
|
949
|
+
onSelect: () => openStackViewer(deps)
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
title: "agent-router: edit stack",
|
|
953
|
+
value: "agent-router.edit",
|
|
954
|
+
description: "Reassign a model inside a stack",
|
|
955
|
+
category: "agent-router",
|
|
956
|
+
slash: { name: "agent-edit" },
|
|
957
|
+
onSelect: () => openStackEditor(deps)
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
title: "agent-router: undo last switch",
|
|
961
|
+
value: "agent-router.back",
|
|
962
|
+
description: "Revert to the previously active stack",
|
|
963
|
+
category: "agent-router",
|
|
964
|
+
slash: { name: "agent-back" },
|
|
965
|
+
onSelect: () => openBackConfirm(deps)
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
title: "agent-router: validate stack",
|
|
969
|
+
value: "agent-router.validate",
|
|
970
|
+
description: "Check a stack's model IDs against reachable models",
|
|
971
|
+
category: "agent-router",
|
|
972
|
+
slash: { name: "agent-validate" },
|
|
973
|
+
onSelect: () => openValidator(deps)
|
|
974
|
+
}
|
|
975
|
+
);
|
|
976
|
+
return commands;
|
|
977
|
+
}
|
|
978
|
+
var tui = async (api) => {
|
|
979
|
+
debugLog("tui() entered");
|
|
980
|
+
const solid = await importHostSolid();
|
|
981
|
+
if (!solid) return;
|
|
982
|
+
const paths = await resolvePathsWithConfig().catch(() => null);
|
|
983
|
+
if (!paths) return;
|
|
984
|
+
let snapshot = await readStackSnapshot(paths);
|
|
985
|
+
const bootActive = snapshot.active;
|
|
986
|
+
const viewContext = () => ({ bootActive, theme: api.theme?.current });
|
|
987
|
+
try {
|
|
988
|
+
api.slots.register({
|
|
989
|
+
order: SIDEBAR_ORDER,
|
|
990
|
+
slots: {
|
|
991
|
+
sidebar_content: () => materialize(buildSidebarNodes(snapshot, viewContext()), solid)
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
debugLog("slots.register ok");
|
|
995
|
+
} catch (e) {
|
|
996
|
+
debugLog(`slots.register FAILED: ${e.message}`);
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
api.renderer.requestRender();
|
|
1000
|
+
const applySnapshot = (next) => {
|
|
1001
|
+
snapshot = next;
|
|
1002
|
+
api.renderer.requestRender();
|
|
1003
|
+
};
|
|
1004
|
+
const refresh = () => {
|
|
1005
|
+
readStackSnapshot(paths).then(applySnapshot).catch(() => {
|
|
1006
|
+
});
|
|
1007
|
+
};
|
|
1008
|
+
const stopPolling = createSidebarPoller({
|
|
1009
|
+
read: () => readStackSnapshot(paths),
|
|
1010
|
+
intervalMs: POLL_INTERVAL_MS,
|
|
1011
|
+
initial: snapshot,
|
|
1012
|
+
onChange: (next, prev) => {
|
|
1013
|
+
applySnapshot(next);
|
|
1014
|
+
if (next.active !== prev.active) {
|
|
1015
|
+
const suffix = next.active === bootActive ? "" : " \u2014 restart opencode to apply";
|
|
1016
|
+
api.ui.toast({
|
|
1017
|
+
title: "agent-router",
|
|
1018
|
+
message: `stack \u2192 ${next.active ?? "(none)"}${suffix}`,
|
|
1019
|
+
variant: "info"
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
const deps = { api, paths, refresh };
|
|
1025
|
+
const status = () => api.ui.toast({
|
|
1026
|
+
title: "agent-router",
|
|
1027
|
+
message: `active: ${snapshot.active ?? "(none)"} \xB7 stacks: ${snapshot.stacks.join(", ") || "(none)"}`,
|
|
1028
|
+
variant: "info"
|
|
1029
|
+
});
|
|
1030
|
+
try {
|
|
1031
|
+
api.command?.register(() => buildCommands(api, deps, status));
|
|
1032
|
+
debugLog(`commands registered (dialogs=${canOpenDialogs(api)})`);
|
|
1033
|
+
} catch (e) {
|
|
1034
|
+
debugLog(`command.register FAILED: ${e.message}`);
|
|
1035
|
+
}
|
|
1036
|
+
api.lifecycle.onDispose(stopPolling);
|
|
1037
|
+
debugLog(`tui() init complete \u2014 active=${snapshot.active ?? "(none)"}`);
|
|
1038
|
+
};
|
|
1039
|
+
var agentRouterTui = {
|
|
1040
|
+
id: "agent-router:tui",
|
|
1041
|
+
tui
|
|
1042
|
+
};
|
|
1043
|
+
var tui_default = agentRouterTui;
|
|
1044
|
+
export {
|
|
1045
|
+
tui_default as default,
|
|
1046
|
+
tui
|
|
1047
|
+
};
|
|
1048
|
+
//# sourceMappingURL=tui.js.map
|