agent-runway 0.2.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.
@@ -0,0 +1,425 @@
1
+ // Which copies of each agent exist on this machine.
2
+ //
3
+ // "Claude" or "Codex" is not a precise enough answer for an agent about to
4
+ // spawn one: the same machine carries several, and they disagree. Measured
5
+ // here, all four diverge — the Claude CLI on PATH is 2.1.241 while its VS Code
6
+ // extension bundles 2.1.269, and the Codex CLI is 0.149.1 while its extension
7
+ // runs 0.153.4. A model the extension offers may be unknown to the binary a
8
+ // delegation would actually invoke.
9
+ //
10
+ // So the unit is the install, identified by its path, and a fingerprint that
11
+ // changes whenever the file does.
12
+
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { spawnSync } from "node:child_process";
17
+
18
+ import * as cache from "./cache.mjs";
19
+
20
+ const IS_WINDOWS = process.platform === "win32";
21
+ const SPAWN = { encoding: "utf-8", windowsHide: true, timeout: 8000 };
22
+
23
+ export const AGENTS = {
24
+ claude: {
25
+ label: "Claude Code",
26
+ binary: "claude",
27
+ vscode: { publisher: "anthropic.claude-code", bundled: ["resources", "native-binary"] },
28
+ // The desktop app keeps its own copies under a directory named for each
29
+ // version, so the version comes free from the path. Two sit side by side
30
+ // here, distinct from both the npm install and the VS Code extensions.
31
+ //
32
+ // These are the app's user-data directories, which is where the embedded
33
+ // binaries live whatever the install source: a Microsoft Store package and
34
+ // an installer put the app itself in different places, but both write their
35
+ // agent builds here, so the install method does not have to be detected.
36
+ //
37
+ // No Linux entry, and that is a finding rather than an omission: there is no
38
+ // Claude desktop app for Linux, so an earlier guess at ~/.config/Claude was
39
+ // a path that could never match. (Established in brainclaw's surface
40
+ // inventory, which classifies Claude on Linux as a web surface.)
41
+ //
42
+ // Only the Windows layout has been observed here; macOS follows Electron's
43
+ // user-data convention and says so.
44
+ desktop: {
45
+ win32: ["AppData", "Roaming", "Claude", "claude-code"],
46
+ darwin: ["Library", "Application Support", "Claude", "claude-code"],
47
+ verifiedOn: ["win32"],
48
+ },
49
+ },
50
+ codex: {
51
+ label: "OpenAI Codex",
52
+ binary: "codex",
53
+ vscode: { publisher: "openai.chatgpt", bundled: null },
54
+ // `codex app` launches a desktop build. Not installed on the machine this
55
+ // was written against — it conflicts with the VS Code extension over MCP
56
+ // definitions — so no layout is guessed at here rather than shipping a path
57
+ // nobody has verified.
58
+ desktop: null,
59
+ },
60
+ copilot: {
61
+ label: "GitHub Copilot",
62
+ binary: "copilot",
63
+ vscode: null, // Copilot Chat ships inside VS Code rather than as an extension
64
+ desktop: null,
65
+ },
66
+ // Spawnable with a model, unlike Antigravity, which is why it earns an entry
67
+ // even though its quota is not read separately: it shares Google's.
68
+ gemini: {
69
+ label: "Gemini CLI",
70
+ binary: "gemini",
71
+ vscode: null,
72
+ desktop: null,
73
+ },
74
+ antigravity: {
75
+ label: "Antigravity",
76
+ binary: "antigravity",
77
+ vscode: null,
78
+ desktop: null,
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Where each CLI sits when PATH cannot be consulted.
84
+ *
85
+ * An MCP server is spawned by its client with a deliberately minimal
86
+ * environment — the official SDK passes only a small set of variables through —
87
+ * so `where` finds nothing and every PATH install disappears. Editor and
88
+ * desktop installs survive because they are found by absolute path; these give
89
+ * the CLIs the same footing.
90
+ *
91
+ * Every entry here was observed on a real machine rather than guessed.
92
+ */
93
+ const KNOWN_LOCATIONS = {
94
+ win32: {
95
+ claude: ["AppData/Roaming/npm/node_modules/@anthropic-ai/claude-code/bin/claude.exe"],
96
+ codex: ["AppData/Local/Programs/OpenAI/Codex/bin/codex.exe"],
97
+ copilot: ["AppData/Roaming/npm/copilot.cmd"],
98
+ gemini: ["AppData/Roaming/npm/gemini.cmd"],
99
+ antigravity: ["AppData/Local/Programs/Antigravity/bin/antigravity.exe"],
100
+ },
101
+ // Unverified: this project has only ever run on Windows. The paths follow the
102
+ // usual global-npm and application conventions, and a miss simply yields no
103
+ // install rather than a wrong one.
104
+ darwin: {
105
+ claude: [".npm-global/bin/claude", "/usr/local/bin/claude"],
106
+ codex: [".codex/bin/codex", "/usr/local/bin/codex"],
107
+ copilot: [".npm-global/bin/copilot", "/usr/local/bin/copilot"],
108
+ gemini: [".npm-global/bin/gemini", "/usr/local/bin/gemini"],
109
+ },
110
+ linux: {
111
+ claude: [".npm-global/bin/claude", "/usr/local/bin/claude"],
112
+ codex: [".codex/bin/codex", "/usr/local/bin/codex"],
113
+ copilot: [".npm-global/bin/copilot", "/usr/local/bin/copilot"],
114
+ gemini: [".npm-global/bin/gemini", "/usr/local/bin/gemini"],
115
+ },
116
+ };
117
+
118
+ function knownLocation(agent, home, platform) {
119
+ for (const candidate of KNOWN_LOCATIONS[platform]?.[agent] ?? []) {
120
+ const file = path.isAbsolute(candidate) ? candidate : path.join(home, candidate);
121
+ if (fs.existsSync(file)) return file;
122
+ }
123
+ return null;
124
+ }
125
+
126
+ /**
127
+ * Resolve a binary on PATH without launching it.
128
+ *
129
+ * `where` returns every match, and the first is not always the usable one:
130
+ * `where codex` lists the real executable, an extensionless sh script and a
131
+ * .cmd launcher, and their order changes with PATH — under `npm run` the sh
132
+ * script came first, which Windows cannot spawn at all (EFTYPE). Rank by what
133
+ * can actually be executed rather than trusting the order.
134
+ */
135
+ function onPath(binary) {
136
+ try {
137
+ const r = spawnSync(IS_WINDOWS ? "where" : "which", [binary], { ...SPAWN, timeout: 3000 });
138
+ if (r.status !== 0) return null;
139
+
140
+ const matches = (r.stdout ?? "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
141
+ if (!IS_WINDOWS) return matches[0] ?? null;
142
+
143
+ const rank = (file) => (/\.exe$/i.test(file) ? 0 : /\.(cmd|bat)$/i.test(file) ? 1 : 2);
144
+ return [...matches].sort((a, b) => rank(a) - rank(b))[0] ?? null;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * PATH usually hands back an npm shim, not the program.
152
+ *
153
+ * `where claude` returns a 308-byte shell script; the binary it launches is
154
+ * 337MB. Fingerprinting or scanning the shim would describe the wrong file
155
+ * entirely — and the shim never changes when the package is upgraded, so it is
156
+ * also useless as a cache key. The script names its target, so follow it.
157
+ */
158
+ export function followShim(file) {
159
+ let size;
160
+ try {
161
+ size = fs.statSync(file).size;
162
+ } catch {
163
+ return file;
164
+ }
165
+ if (size > 64 * 1024) return file; // already a real binary
166
+
167
+ let text;
168
+ try {
169
+ text = fs.readFileSync(file, "utf8");
170
+ } catch {
171
+ return file;
172
+ }
173
+
174
+ const target = text.match(/node_modules[\/\\][^\s"'`]+?\.(?:exe|js|cjs|mjs)/)?.[0];
175
+ if (!target) return file;
176
+
177
+ const resolved = path.resolve(path.dirname(file), target.replace(/\\/g, "/"));
178
+ return fs.existsSync(resolved) ? resolved : file;
179
+ }
180
+
181
+ /**
182
+ * Identity of a file, cheap enough to call on every lookup.
183
+ *
184
+ * Used as the cache key for anything derived from a binary: size and mtime
185
+ * change on every upgrade, and also on a reinstall of the same version, which a
186
+ * reported version string would miss.
187
+ */
188
+ export function fingerprint(file) {
189
+ try {
190
+ const s = fs.statSync(file);
191
+ return `${s.size}-${Math.round(s.mtimeMs)}`;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * A version, cached against the file's fingerprint.
199
+ *
200
+ * Probing costs seconds — these binaries are hundreds of megabytes — but the
201
+ * answer only changes when the file does, whether that is a CLI upgrade or a
202
+ * new VS Code extension build. The fingerprint is the invalidation: a changed
203
+ * binary is a different key, so nothing expires and nothing goes stale.
204
+ */
205
+ export function versionOf(file, { launcher = null, fp = fingerprint(file) } = {}) {
206
+ if (!fp) return probeVersion(launcher ?? file);
207
+
208
+ const key = `version-${path.basename(file)}-${fp}`;
209
+ const hit = cache.read(key, Infinity, Infinity);
210
+ if (hit) return hit.value;
211
+
212
+ // Probe through the launcher, because that is how the program is meant to be
213
+ // started — a loader script will not answer --version on its own — but key
214
+ // the result on the program, which is what actually changes on upgrade.
215
+ const version = probeVersion(launcher ?? file) ?? probeVersion(file);
216
+ // A null is cached too: without it, an agent that never reports a version
217
+ // would be re-probed on every call, which is the cost this exists to avoid.
218
+ cache.write(key, version ?? null);
219
+ return version;
220
+ }
221
+
222
+ /** Only spawn when a version is actually wanted: these binaries are hundreds of MB. */
223
+ export function probeVersion(file) {
224
+ try {
225
+ const r = IS_WINDOWS
226
+ ? spawnSync(`"${file}" --version`, { ...SPAWN, shell: true })
227
+ : spawnSync(file, ["--version"], SPAWN);
228
+ if (r.status !== 0) return null;
229
+ return (r.stdout ?? "").trim().match(/(\d+\.\d+\.\d+)/)?.[1] ?? null;
230
+ } catch {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ const vscodeDirs = (home) => {
236
+ const roots = [path.join(home, ".vscode", "extensions"), path.join(home, ".vscode-insiders", "extensions")];
237
+ const out = [];
238
+ for (const root of roots) {
239
+ try {
240
+ for (const entry of fs.readdirSync(root)) out.push({ root, entry });
241
+ } catch {
242
+ /* no such editor installed */
243
+ }
244
+ }
245
+ return out;
246
+ };
247
+
248
+ /**
249
+ * Desktop apps that keep one directory per version of the agent they embed.
250
+ *
251
+ * Claude Desktop does this under its user-data directory, which means a machine
252
+ * can carry several more copies than PATH and the editor extensions reveal —
253
+ * five for Claude here, against the three found before this was added.
254
+ */
255
+ function desktopInstalls(spec, home, platform, binaryName) {
256
+ const segments = spec?.[platform];
257
+ if (!segments) return [];
258
+
259
+ const root = path.join(home, ...segments);
260
+ let versions;
261
+ try {
262
+ versions = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
263
+ } catch {
264
+ return []; // app not installed, or a layout this has never seen
265
+ }
266
+
267
+ const exe = platform === "win32" ? `${binaryName}.exe` : binaryName;
268
+ const found = [];
269
+ for (const dir of versions) {
270
+ const file = path.join(root, dir.name, exe);
271
+ if (!fs.existsSync(file)) continue;
272
+ found.push({
273
+ path: file,
274
+ // The directory is named for the version, so no probe is needed.
275
+ version: /^\d+\.\d+\.\d+/.test(dir.name) ? dir.name : null,
276
+ verified: (spec.verifiedOn ?? []).includes(platform),
277
+ });
278
+ }
279
+ return found;
280
+ }
281
+
282
+ /** `anthropic.claude-code-2.1.269-win32-x64` -> "2.1.269" */
283
+ function versionFromFolder(name, publisher) {
284
+ return name.startsWith(publisher + "-")
285
+ ? name.slice(publisher.length + 1).match(/^(\d+\.\d+\.\d+)/)?.[1] ?? null
286
+ : null;
287
+ }
288
+
289
+ function bundledBinary(dir, segments, binary) {
290
+ if (!segments) return null;
291
+ for (const name of [binary + ".exe", binary]) {
292
+ const candidate = path.join(dir, ...segments, name);
293
+ if (fs.existsSync(candidate)) return candidate;
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /**
299
+ * The Codex extension ships per-platform binaries under bin/<target>/ with a
300
+ * manifest naming the version and entrypoint, which beats guessing at both.
301
+ * Its version is its own: 0.154.0-alpha.6.1 here, against 0.149.1 on PATH.
302
+ */
303
+ function codexExtensionBinary(dir) {
304
+ const platforms = IS_WINDOWS ? ["windows-x86_64"] : ["linux-x86_64", "darwin-arm64", "darwin-x86_64"];
305
+ for (const platform of platforms) {
306
+ const base = path.join(dir, "bin", platform);
307
+ let manifest;
308
+ try {
309
+ manifest = JSON.parse(fs.readFileSync(path.join(base, "codex-package.json"), "utf8"));
310
+ } catch {
311
+ continue;
312
+ }
313
+ // The entrypoint is written as "bin/codex.exe" but the platform directory
314
+ // IS that bin: joining the two produced bin/windows-x86_64/bin/codex.exe,
315
+ // which does not exist, and made a binary that was present look missing.
316
+ // Resolve by filename inside the platform directory, keeping the literal
317
+ // join as a fallback in case a future layout means it.
318
+ const name = manifest.entrypoint ? path.basename(manifest.entrypoint) : null;
319
+ const entry = [
320
+ name ? path.join(base, name) : null,
321
+ manifest.entrypoint ? path.join(base, manifest.entrypoint) : null,
322
+ ].find((candidate) => candidate && fs.existsSync(candidate));
323
+
324
+ return {
325
+ path: entry ?? base,
326
+ version: manifest.version ?? null,
327
+ resolved: Boolean(entry),
328
+ };
329
+ }
330
+ return null;
331
+ }
332
+
333
+ /**
334
+ * @param {object} [options]
335
+ * @param {boolean} [options.withVersions] probe PATH binaries for a version string; costs seconds
336
+ * @returns {Array<{agent, label, kind, path, version, fingerprint}>}
337
+ */
338
+ export function discoverInstalls({
339
+ home = os.homedir(),
340
+ platform = process.platform,
341
+ withVersions = false,
342
+ agents,
343
+ } = {}) {
344
+ const wanted = agents?.length ? agents : Object.keys(AGENTS);
345
+ const found = [];
346
+
347
+ for (const id of wanted) {
348
+ const spec = AGENTS[id];
349
+ if (!spec) continue;
350
+
351
+ for (const app of desktopInstalls(spec.desktop, home, platform, spec.binary)) {
352
+ found.push({
353
+ agent: id,
354
+ label: spec.label,
355
+ kind: "desktop",
356
+ path: app.path,
357
+ version: app.version,
358
+ layoutVerified: app.verified,
359
+ fingerprint: fingerprint(app.path),
360
+ });
361
+ }
362
+
363
+ // A PATH match is not automatically usable. On Windows an extensionless
364
+ // entry is a POSIX shell script that Windows cannot spawn at all (EFTYPE),
365
+ // and `where codex` lists one alongside the real executable. Prefer a known
366
+ // location over a match that cannot be launched, rather than only falling
367
+ // back when PATH finds nothing.
368
+ const fromPath = onPath(spec.binary);
369
+ const launchable = !fromPath || platform !== "win32" || /\.(exe|cmd|bat)$/i.test(fromPath);
370
+ const shim = (launchable ? fromPath : null) ?? knownLocation(id, home, platform) ?? fromPath;
371
+
372
+ if (shim) {
373
+ // Fingerprint the program, not the launcher: the shim is unchanged by an
374
+ // upgrade, so keying a cache on it would never invalidate.
375
+ const real = followShim(shim);
376
+ found.push({
377
+ agent: id,
378
+ label: spec.label,
379
+ kind: "path",
380
+ path: real,
381
+ launcher: real === shim ? null : shim,
382
+ version: withVersions ? versionOf(real, { launcher: shim }) : null,
383
+ fingerprint: fingerprint(real),
384
+ });
385
+ }
386
+
387
+ if (!spec.vscode) continue;
388
+ for (const { root, entry } of vscodeDirs(home)) {
389
+ const version = versionFromFolder(entry, spec.vscode.publisher);
390
+ if (!version) continue;
391
+ const dir = path.join(root, entry);
392
+ const codex = id === "codex" ? codexExtensionBinary(dir) : null;
393
+ const binary = codex?.path ?? bundledBinary(dir, spec.vscode.bundled, spec.binary);
394
+ found.push({
395
+ agent: id,
396
+ label: spec.label,
397
+ kind: "vscode",
398
+ path: binary ?? dir,
399
+ // Free: the extension folder carries a version, and Codex's manifest
400
+ // carries the more precise one for the binary it actually ships.
401
+ version: codex?.version ?? version,
402
+ extensionVersion: version,
403
+ fingerprint: binary && codex?.resolved !== false ? fingerprint(binary) : null,
404
+ });
405
+ }
406
+ }
407
+
408
+ return found;
409
+ }
410
+
411
+ /** Group by agent, so a caller can see the disagreement at a glance. */
412
+ export function byAgent(installs) {
413
+ const map = new Map();
414
+ for (const i of installs) {
415
+ if (!map.has(i.agent)) map.set(i.agent, []);
416
+ map.get(i.agent).push(i);
417
+ }
418
+ return map;
419
+ }
420
+
421
+ /** True when an agent has installs that do not agree on a version. */
422
+ export function hasVersionSkew(installs) {
423
+ const versions = new Set(installs.map((i) => i.version).filter(Boolean));
424
+ return versions.size > 1;
425
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ // MCP stdio server: the same three questions the CLI answers, exposed as tools
3
+ // an agent can call for itself.
4
+ //
5
+ // Deliberately dependency-free: a Claude Code plugin installed from git is not
6
+ // npm-installed, so anything imported here would have to be vendored. The
7
+ // protocol surface needed is small, and scripts/smoke-mcp.mjs drives it with
8
+ // the official SDK client so the hand-rolled framing stays honest.
9
+ //
10
+ // stdout carries the JSON-RPC stream. Nothing else may ever be written to it;
11
+ // diagnostics go to stderr.
12
+
13
+ import { createInterface } from "node:readline";
14
+
15
+ import { VERSION } from "./core.mjs";
16
+
17
+ const SUPPORTED_PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
18
+ const DEFAULT_PROTOCOL = SUPPORTED_PROTOCOLS[0];
19
+
20
+ const PROVIDERS = ["claude", "codex", "copilot", "antigravity"];
21
+ const SPAWNABLE_AGENTS = ["claude", "codex", "copilot"];
22
+
23
+ // Permissive on purpose: these payloads describe undocumented upstreams that
24
+ // can grow a field without warning, and a strict schema would turn that into a
25
+ // client-side validation error rather than an extra key nobody reads.
26
+ const LOOSE = { type: "object", additionalProperties: true };
27
+
28
+ const TOOLS = [
29
+ {
30
+ name: "get_usage",
31
+ title: "Read remaining runway",
32
+ description:
33
+ "How much quota is left on each coding agent signed in on this machine: " +
34
+ "percentage consumed of every rate-limit window, and when each resets. " +
35
+ "Covers Claude, Codex, GitHub Copilot and Antigravity, each read with the " +
36
+ "credentials that agent already keeps. One provider failing never stops " +
37
+ "the others. Use before a long task or a fan-out of subagents. Returns no " +
38
+ "credentials.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ provider: {
43
+ type: "string",
44
+ enum: [...PROVIDERS, "all"],
45
+ description: "Which agent to read. Defaults to all of them.",
46
+ },
47
+ },
48
+ additionalProperties: false,
49
+ },
50
+ outputSchema: {
51
+ type: "object",
52
+ properties: { providers: { type: "array", items: LOOSE } },
53
+ required: ["providers"],
54
+ additionalProperties: true,
55
+ },
56
+ },
57
+ {
58
+ name: "check_capacity",
59
+ title: "Decide whether there is room to work",
60
+ description:
61
+ "Answers whether work can start now, rather than reporting numbers to " +
62
+ "interpret. Each provider comes back as proceed, defer or unknown - three " +
63
+ "outcomes because an agent that cannot be read has not got room, it is " +
64
+ "simply unknown. Defer carries the time its binding window resets, which " +
65
+ "is when the window rolls over and not a promise that service resumes " +
66
+ "exactly then. The recommendation states its own rule and whether the " +
67
+ "candidates were comparable at all: 0% of a five-hour window is not 0% of " +
68
+ "a monthly allowance.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ threshold: {
73
+ type: "number",
74
+ minimum: 0,
75
+ maximum: 100,
76
+ description:
77
+ "Percent consumed above which to defer. A policy, not a fact: at 92% " +
78
+ "the provider is not blocked, your rule says do not start. Default 90.",
79
+ },
80
+ },
81
+ additionalProperties: false,
82
+ },
83
+ outputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ threshold: { type: "number" },
87
+ providers: { type: "array", items: LOOSE },
88
+ anyUnknown: { type: "boolean" },
89
+ },
90
+ required: ["threshold", "providers", "anyUnknown"],
91
+ additionalProperties: true,
92
+ },
93
+ },
94
+ {
95
+ name: "list_models",
96
+ title: "Which models an installed agent will accept",
97
+ description:
98
+ "Before spawning another agent, the slug its binary actually accepts. A " +
99
+ "machine carries several builds of the same agent - a CLI on PATH, one " +
100
+ "inside a VS Code extension, one inside a desktop app - and they disagree: " +
101
+ "a model offered by one is rejected by another. Each catalogue says how it " +
102
+ "was obtained, declared when the binary was asked and answered, inferred " +
103
+ "when identifiers were read out of it. Treat inferred as strong evidence " +
104
+ "rather than a contract, and be ready for a spawn to fail anyway. Also " +
105
+ "reports which installs disagree.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {
109
+ agent: {
110
+ type: "string",
111
+ enum: SPAWNABLE_AGENTS,
112
+ description:
113
+ "Restrict to one agent. Only agents that can be spawned with a model " +
114
+ "argument are covered; an IDE is not one of them.",
115
+ },
116
+ },
117
+ additionalProperties: false,
118
+ },
119
+ outputSchema: {
120
+ type: "object",
121
+ properties: {
122
+ catalogues: { type: "array", items: LOOSE },
123
+ skew: { type: "array", items: LOOSE },
124
+ },
125
+ required: ["catalogues", "skew"],
126
+ additionalProperties: true,
127
+ },
128
+ },
129
+ ];
130
+
131
+ function send(message) {
132
+ process.stdout.write(`${JSON.stringify(message)}\n`);
133
+ }
134
+
135
+ const reply = (id, result) => send({ jsonrpc: "2.0", id, result });
136
+ const replyError = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
137
+
138
+ const answer = (text, structuredContent) => ({ content: [{ type: "text", text }], structuredContent });
139
+
140
+ // ------------------------------------------------------------------ handlers
141
+
142
+ async function getUsage(args) {
143
+ const { readAll } = await import("./providers/index.mjs");
144
+ const { renderProviders } = await import("./render.mjs");
145
+
146
+ const wanted = args?.provider && args.provider !== "all" ? [args.provider] : undefined;
147
+ const providers = await readAll(wanted ? { providers: wanted } : {});
148
+
149
+ return answer(renderProviders(providers), { providers });
150
+ }
151
+
152
+ async function checkCapacity(args) {
153
+ const { readAll, capacity } = await import("./providers/index.mjs");
154
+ const decision = capacity(await readAll(), { threshold: args?.threshold ?? 90 });
155
+
156
+ const lines = decision.providers.map(
157
+ (p) => `${p.provider}: ${p.decision}${p.binding ? ` (${p.binding.label} ${p.binding.percentUsed}%)` : ""}` +
158
+ `${p.retryAt ? ` - retry at ${p.retryAt}` : ""}`
159
+ );
160
+ if (decision.recommended) {
161
+ lines.push("");
162
+ lines.push(
163
+ `recommended: ${decision.recommended.provider}, ${decision.recommended.percentUsed}% of ` +
164
+ `${decision.recommended.window}` +
165
+ (decision.recommended.comparable ? "" : " (candidates are not directly comparable)")
166
+ );
167
+ }
168
+ return answer(lines.join("\n"), decision);
169
+ }
170
+
171
+ async function listModels(args) {
172
+ const [{ discoverInstalls }, models, { renderModels }] = await Promise.all([
173
+ import("./installs.mjs"),
174
+ import("./models.mjs"),
175
+ import("./render.mjs"),
176
+ ]);
177
+
178
+ const installs = discoverInstalls({ withVersions: true }).filter(
179
+ (i) => models.SPAWNABLE.includes(i.agent) && (!args?.agent || i.agent === args.agent)
180
+ );
181
+ const catalogues = await models.modelsForAll(installs);
182
+ const skew = models.modelSkew(catalogues);
183
+
184
+ return answer(renderModels(catalogues, skew), { catalogues, skew });
185
+ }
186
+
187
+ const HANDLERS = { get_usage: getUsage, check_capacity: checkCapacity, list_models: listModels };
188
+
189
+ async function callTool(params) {
190
+ const handler = HANDLERS[params?.name];
191
+ if (!handler) return null;
192
+
193
+ try {
194
+ return await handler(params.arguments ?? {});
195
+ } catch (error) {
196
+ // A failed lookup is a tool-level error, not a protocol one: the client
197
+ // should surface it to the model rather than tear down the connection.
198
+ return { content: [{ type: "text", text: `Failed: ${error?.message ?? error}` }], isError: true };
199
+ }
200
+ }
201
+
202
+ // ------------------------------------------------------------------ protocol
203
+
204
+ async function handle(message) {
205
+ const { id, method, params } = message;
206
+ const isNotification = id === undefined || id === null;
207
+
208
+ switch (method) {
209
+ case "initialize": {
210
+ const requested = params?.protocolVersion;
211
+ const protocolVersion = SUPPORTED_PROTOCOLS.includes(requested) ? requested : DEFAULT_PROTOCOL;
212
+ reply(id, {
213
+ protocolVersion,
214
+ capabilities: { tools: { listChanged: false } },
215
+ serverInfo: { name: "agent-runway", version: VERSION },
216
+ });
217
+ return;
218
+ }
219
+
220
+ case "notifications/initialized":
221
+ case "notifications/cancelled":
222
+ return; // notifications take no response
223
+
224
+ case "ping":
225
+ reply(id, {});
226
+ return;
227
+
228
+ case "tools/list":
229
+ reply(id, { tools: TOOLS });
230
+ return;
231
+
232
+ case "tools/call": {
233
+ const result = await callTool(params);
234
+ if (!result) return replyError(id, -32602, `Unknown tool: ${params?.name}`);
235
+ reply(id, result);
236
+ return;
237
+ }
238
+
239
+ default:
240
+ if (!isNotification) replyError(id, -32601, `Method not found: ${method}`);
241
+ }
242
+ }
243
+
244
+ const input = createInterface({ input: process.stdin });
245
+
246
+ // A tools/call does network and process work, so a request can still be in
247
+ // flight when stdin closes. Track them and drain before exiting, otherwise
248
+ // piped input loses the response.
249
+ const inFlight = new Set();
250
+
251
+ input.on("line", (line) => {
252
+ const trimmed = line.trim();
253
+ if (!trimmed) return;
254
+
255
+ let message;
256
+ try {
257
+ message = JSON.parse(trimmed);
258
+ } catch {
259
+ send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
260
+ return;
261
+ }
262
+
263
+ const pending = handle(message)
264
+ .catch((error) => {
265
+ process.stderr.write(`agent-runway-mcp: ${error?.stack ?? error}\n`);
266
+ if (message?.id != null) replyError(message.id, -32603, "Internal error");
267
+ })
268
+ .finally(() => inFlight.delete(pending));
269
+
270
+ inFlight.add(pending);
271
+ });
272
+
273
+ input.on("close", async () => {
274
+ await Promise.allSettled([...inFlight]);
275
+ // No process.exit() here: it tears down stdout mid-write, which aborts the
276
+ // process on Windows. With stdin closed and nothing pending, the event loop
277
+ // empties and Node exits once the last write has flushed.
278
+ process.exitCode = 0;
279
+ });