@agentproto/cli 0.1.0-alpha.3 → 0.1.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +3857 -66
- package/dist/cli.mjs.map +1 -1
- package/dist/index.mjs +3708 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
package/dist/index.mjs
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import { spawn } from 'child_process';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
|
-
import { mkdtemp, writeFile, chmod, rm, mkdir, readFile } from 'fs/promises';
|
|
1
|
+
import { execFile, spawn } from 'child_process';
|
|
2
|
+
import { createHash, randomUUID, randomBytes } from 'crypto';
|
|
3
|
+
import { mkdtemp, writeFile, chmod, rm, mkdir, readFile, readdir, stat } from 'fs/promises';
|
|
4
4
|
import { tmpdir, userInfo, hostname, homedir, platform } from 'os';
|
|
5
|
-
import { resolve, join, dirname } from 'path';
|
|
6
|
-
import { parseArgs } from 'util';
|
|
7
|
-
import { promises } from 'fs';
|
|
5
|
+
import { resolve, join, dirname, isAbsolute, normalize, relative, basename } from 'path';
|
|
6
|
+
import { promisify, parseArgs } from 'util';
|
|
7
|
+
import { promises, existsSync } from 'fs';
|
|
8
8
|
import { createInterface } from 'readline/promises';
|
|
9
9
|
import { stdout, stdin } from 'process';
|
|
10
10
|
import { createAgentCliRuntime } from '@agentproto/driver-agent-cli';
|
|
11
11
|
import { wrapWebSocket, createTunnelServer } from '@agentproto/acp/tunnel';
|
|
12
|
-
import {
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import matter from 'gray-matter';
|
|
16
|
+
import { EventEmitter } from 'events';
|
|
17
|
+
import { createServer } from 'http';
|
|
18
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
19
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
21
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
22
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
13
23
|
import WebSocket from 'ws';
|
|
14
24
|
|
|
15
25
|
/**
|
|
@@ -92,8 +102,8 @@ async function collectAgentprotoNamespaceRoots(start) {
|
|
|
92
102
|
for (let depth = 0; depth < 16; depth++) {
|
|
93
103
|
for (const candidate of candidatesAt(cur)) {
|
|
94
104
|
try {
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
105
|
+
const stat2 = await promises.stat(candidate);
|
|
106
|
+
if (stat2.isDirectory()) roots.push(candidate);
|
|
97
107
|
} catch {
|
|
98
108
|
}
|
|
99
109
|
}
|
|
@@ -249,10 +259,10 @@ async function runExternalStep(step, ledger, head) {
|
|
|
249
259
|
`);
|
|
250
260
|
const opener = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
|
|
251
261
|
const args = platform() === "win32" ? ["/c", "start", step.url] : [step.url];
|
|
252
|
-
await new Promise((
|
|
262
|
+
await new Promise((resolve3) => {
|
|
253
263
|
const child = spawn(opener, args, { stdio: "ignore", detached: true });
|
|
254
|
-
child.once("error", () =>
|
|
255
|
-
child.once("spawn", () =>
|
|
264
|
+
child.once("error", () => resolve3());
|
|
265
|
+
child.once("spawn", () => resolve3());
|
|
256
266
|
});
|
|
257
267
|
let value = "";
|
|
258
268
|
if (step.callback?.param) {
|
|
@@ -325,7 +335,7 @@ async function promptString(question, opts) {
|
|
|
325
335
|
process.stdin.setRawMode?.(true);
|
|
326
336
|
let buf = "";
|
|
327
337
|
process.stdout.write(prompt);
|
|
328
|
-
return new Promise((
|
|
338
|
+
return new Promise((resolve3) => {
|
|
329
339
|
const onData = (chunk) => {
|
|
330
340
|
for (const code of chunk) {
|
|
331
341
|
if (code === 13 || code === 10) {
|
|
@@ -333,7 +343,7 @@ async function promptString(question, opts) {
|
|
|
333
343
|
process.stdin.setRawMode?.(false);
|
|
334
344
|
process.stdout.write("\n");
|
|
335
345
|
rl.close();
|
|
336
|
-
|
|
346
|
+
resolve3(buf || opts.defaultValue || "");
|
|
337
347
|
return;
|
|
338
348
|
}
|
|
339
349
|
if (code === 3) {
|
|
@@ -415,7 +425,7 @@ async function resolveSelectOptions(options) {
|
|
|
415
425
|
});
|
|
416
426
|
}
|
|
417
427
|
async function runShellCapturing(cmd, opts) {
|
|
418
|
-
return new Promise((
|
|
428
|
+
return new Promise((resolve3) => {
|
|
419
429
|
const child = spawn("bash", ["-lc", cmd], {
|
|
420
430
|
stdio: opts.interactive ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
421
431
|
});
|
|
@@ -433,11 +443,11 @@ async function runShellCapturing(cmd, opts) {
|
|
|
433
443
|
}, opts.timeoutMs);
|
|
434
444
|
child.once("error", () => {
|
|
435
445
|
clearTimeout(timer);
|
|
436
|
-
|
|
446
|
+
resolve3({ exitCode: 127, stdout, stderr: stderr || "spawn error" });
|
|
437
447
|
});
|
|
438
448
|
child.once("exit", (code) => {
|
|
439
449
|
clearTimeout(timer);
|
|
440
|
-
|
|
450
|
+
resolve3({ exitCode: code ?? 0, stdout, stderr });
|
|
441
451
|
});
|
|
442
452
|
});
|
|
443
453
|
}
|
|
@@ -796,15 +806,15 @@ function homedir2() {
|
|
|
796
806
|
return process.env["HOME"] ?? process.env["USERPROFILE"] ?? "/";
|
|
797
807
|
}
|
|
798
808
|
function spawnInherit(cmd, argv) {
|
|
799
|
-
return new Promise((
|
|
809
|
+
return new Promise((resolve3, reject) => {
|
|
800
810
|
const child = spawn(cmd, argv, { stdio: "inherit" });
|
|
801
811
|
child.once("error", reject);
|
|
802
|
-
child.once("exit", (code) =>
|
|
812
|
+
child.once("exit", (code) => resolve3(code ?? 0));
|
|
803
813
|
});
|
|
804
814
|
}
|
|
805
815
|
async function runVersionCheck(check) {
|
|
806
816
|
if (!check) return { ok: false, message: "no version_check declared" };
|
|
807
|
-
return new Promise((
|
|
817
|
+
return new Promise((resolve3) => {
|
|
808
818
|
const child = spawn("bash", ["-lc", check.cmd], {
|
|
809
819
|
stdio: ["ignore", "pipe", "ignore"]
|
|
810
820
|
});
|
|
@@ -812,19 +822,19 @@ async function runVersionCheck(check) {
|
|
|
812
822
|
child.stdout.on("data", (c) => {
|
|
813
823
|
buf += c.toString("utf8");
|
|
814
824
|
});
|
|
815
|
-
child.once("error", () =>
|
|
825
|
+
child.once("error", () => resolve3({ ok: false, message: "check failed" }));
|
|
816
826
|
child.once("exit", (code) => {
|
|
817
827
|
if (code !== 0) {
|
|
818
|
-
|
|
828
|
+
resolve3({ ok: false, message: `check exited ${code}` });
|
|
819
829
|
return;
|
|
820
830
|
}
|
|
821
831
|
const re = new RegExp(check.parse);
|
|
822
832
|
const m = buf.match(re);
|
|
823
833
|
if (!m || !m[1]) {
|
|
824
|
-
|
|
834
|
+
resolve3({ ok: false, message: "could not parse version" });
|
|
825
835
|
return;
|
|
826
836
|
}
|
|
827
|
-
|
|
837
|
+
resolve3({ ok: true, message: `version ${m[1]}` });
|
|
828
838
|
});
|
|
829
839
|
});
|
|
830
840
|
}
|
|
@@ -836,8 +846,8 @@ async function readStdinIfPiped() {
|
|
|
836
846
|
for await (const chunk of process.stdin) {
|
|
837
847
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
838
848
|
}
|
|
839
|
-
const
|
|
840
|
-
return
|
|
849
|
+
const text3 = Buffer.concat(chunks).toString("utf8").trim();
|
|
850
|
+
return text3.length > 0 ? text3 : null;
|
|
841
851
|
}
|
|
842
852
|
|
|
843
853
|
// src/commands/run.ts
|
|
@@ -1008,6 +1018,3664 @@ function formatRelative(ms) {
|
|
|
1008
1018
|
if (ms < 864e5) return `${Math.round(ms / 36e5)}h`;
|
|
1009
1019
|
return `${Math.round(ms / 864e5)}d`;
|
|
1010
1020
|
}
|
|
1021
|
+
|
|
1022
|
+
// ../define-doctype/dist/index.mjs
|
|
1023
|
+
var DEFAULT_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{1,79}$/;
|
|
1024
|
+
var MAX_DESCRIPTION_LEN = 2e3;
|
|
1025
|
+
function createDoctype(opts) {
|
|
1026
|
+
const idPattern = opts.idPattern ?? DEFAULT_ID_PATTERN;
|
|
1027
|
+
const readIdentity = opts.readIdentity ?? ((def) => def.id);
|
|
1028
|
+
const readDescription = opts.readDescription === false ? null : opts.readDescription ?? ((def) => def.description);
|
|
1029
|
+
const maxLen = opts.maxDescriptionLen ?? MAX_DESCRIPTION_LEN;
|
|
1030
|
+
const prefix = `define${capitalize(opts.name)}`;
|
|
1031
|
+
const aipTag = `(AIP-${opts.aip})`;
|
|
1032
|
+
return function constructDoctype(def) {
|
|
1033
|
+
const identity = readIdentity(def);
|
|
1034
|
+
if (typeof identity !== "string" || !idPattern.test(identity)) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`${prefix} ${aipTag}: invalid id '${String(
|
|
1037
|
+
identity
|
|
1038
|
+
)}' \u2014 must match ${idPattern}`
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
if (readDescription) {
|
|
1042
|
+
const description = readDescription(def);
|
|
1043
|
+
if (typeof description !== "string" || description.length === 0 || description.length > maxLen) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
`${prefix} ${aipTag}: id='${identity}' description must be 1\u2013${maxLen} chars`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
opts.validate?.(def);
|
|
1050
|
+
return Object.freeze(opts.build(def));
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
function capitalize(s) {
|
|
1054
|
+
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
1055
|
+
}
|
|
1056
|
+
function filterSerializable(value) {
|
|
1057
|
+
if (value === null || value === void 0) return value;
|
|
1058
|
+
if (typeof value === "function") return void 0;
|
|
1059
|
+
if (typeof value === "object" && value !== null && Object.prototype.hasOwnProperty.call(value, "_def") && "parse" in value && typeof value.parse === "function") {
|
|
1060
|
+
return void 0;
|
|
1061
|
+
}
|
|
1062
|
+
if (Array.isArray(value)) {
|
|
1063
|
+
return value.map(filterSerializable).filter((v) => v !== void 0);
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof value === "object") {
|
|
1066
|
+
const out = {};
|
|
1067
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1068
|
+
const filtered = filterSerializable(v);
|
|
1069
|
+
if (filtered !== void 0) out[k] = filtered;
|
|
1070
|
+
}
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
return value;
|
|
1074
|
+
}
|
|
1075
|
+
var DEFAULT_SKIP_DIRS = [
|
|
1076
|
+
"node_modules",
|
|
1077
|
+
".git",
|
|
1078
|
+
"dist",
|
|
1079
|
+
".next",
|
|
1080
|
+
".turbo"
|
|
1081
|
+
];
|
|
1082
|
+
function createVerbs(spec) {
|
|
1083
|
+
const filename = spec.filename ?? `${spec.name.toUpperCase()}.md`;
|
|
1084
|
+
const toFrontmatter = spec.toFrontmatter ?? ((params) => filterSerializable({
|
|
1085
|
+
schema: spec.schemaLiteral,
|
|
1086
|
+
...params
|
|
1087
|
+
}));
|
|
1088
|
+
async function create(params, opts) {
|
|
1089
|
+
const handle = spec.define(params);
|
|
1090
|
+
const relativePath = spec.pathOf(handle);
|
|
1091
|
+
const path = join(opts.dir, relativePath);
|
|
1092
|
+
const frontmatter = toFrontmatter(params);
|
|
1093
|
+
const rendered = matter.stringify(
|
|
1094
|
+
opts.body ?? defaultBody(spec, frontmatter),
|
|
1095
|
+
frontmatter
|
|
1096
|
+
);
|
|
1097
|
+
if (!opts.dryRun) {
|
|
1098
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1099
|
+
await writeFile(path, rendered, "utf8");
|
|
1100
|
+
}
|
|
1101
|
+
return { path, handle, rendered };
|
|
1102
|
+
}
|
|
1103
|
+
async function load(path) {
|
|
1104
|
+
const source = await readFile(path, "utf8");
|
|
1105
|
+
const { frontmatter, body } = spec.parse(source);
|
|
1106
|
+
const handle = spec.define(frontmatter);
|
|
1107
|
+
return { path, handle, body };
|
|
1108
|
+
}
|
|
1109
|
+
async function list(dir, opts = {}) {
|
|
1110
|
+
const skip = new Set(opts.skipDirs ?? DEFAULT_SKIP_DIRS);
|
|
1111
|
+
const out = [];
|
|
1112
|
+
async function walk(current) {
|
|
1113
|
+
let entries;
|
|
1114
|
+
try {
|
|
1115
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1116
|
+
} catch {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
for (const entry of entries) {
|
|
1120
|
+
const entryName = String(entry.name);
|
|
1121
|
+
if (entry.isDirectory()) {
|
|
1122
|
+
if (skip.has(entryName)) continue;
|
|
1123
|
+
await walk(join(current, entryName));
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
if (!entry.isFile()) continue;
|
|
1127
|
+
if (entryName !== filename) continue;
|
|
1128
|
+
try {
|
|
1129
|
+
const { handle } = await load(join(current, entryName));
|
|
1130
|
+
if (!opts.filter || opts.filter(handle)) out.push(handle);
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
await walk(dir);
|
|
1136
|
+
return out;
|
|
1137
|
+
}
|
|
1138
|
+
async function update(path, mutator, opts = {}) {
|
|
1139
|
+
const { handle, body } = await load(path);
|
|
1140
|
+
const source = await readFile(path, "utf8");
|
|
1141
|
+
const { frontmatter } = spec.parse(source);
|
|
1142
|
+
const params = frontmatter;
|
|
1143
|
+
const mutated = await mutator(params, { handle, body });
|
|
1144
|
+
const newHandle = spec.define(mutated);
|
|
1145
|
+
const newFrontmatter = toFrontmatter(mutated);
|
|
1146
|
+
const rendered = matter.stringify(
|
|
1147
|
+
opts.body ?? body,
|
|
1148
|
+
newFrontmatter
|
|
1149
|
+
);
|
|
1150
|
+
if (!opts.dryRun) {
|
|
1151
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1152
|
+
await writeFile(path, rendered, "utf8");
|
|
1153
|
+
}
|
|
1154
|
+
return { path, handle: newHandle, rendered };
|
|
1155
|
+
}
|
|
1156
|
+
async function resolve22(block, ctx = {}) {
|
|
1157
|
+
if ("inline" in block) {
|
|
1158
|
+
return spec.define(block.inline);
|
|
1159
|
+
}
|
|
1160
|
+
if ("file" in block) {
|
|
1161
|
+
const baseDir = ctx.baseDir ?? ".";
|
|
1162
|
+
const path = isAbsolute(block.file) ? block.file : join(baseDir, block.file);
|
|
1163
|
+
const { handle } = await load(path);
|
|
1164
|
+
return handle;
|
|
1165
|
+
}
|
|
1166
|
+
if ("ref" in block) {
|
|
1167
|
+
if (!ctx.resolveRef) {
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
`${spec.name}.resolve (AIP-${spec.aip}): block has \`ref: '${block.ref}'\` but no resolveRef provided in context \u2014 registries must inject their own resolver`
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const resolved = await ctx.resolveRef(block.ref);
|
|
1173
|
+
return spec.define(resolved);
|
|
1174
|
+
}
|
|
1175
|
+
throw new Error(
|
|
1176
|
+
`${spec.name}.resolve (AIP-${spec.aip}): block must have one of inline | ref | file`
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
async function deleteFn(path) {
|
|
1180
|
+
await rm(path, { force: true });
|
|
1181
|
+
}
|
|
1182
|
+
return { create, load, list, update, resolve: resolve22, delete: deleteFn };
|
|
1183
|
+
}
|
|
1184
|
+
function defaultBody(spec, frontmatter) {
|
|
1185
|
+
const id = typeof frontmatter.name === "string" ? frontmatter.name : typeof frontmatter.id === "string" ? frontmatter.id : typeof frontmatter.slug === "string" ? frontmatter.slug : spec.name;
|
|
1186
|
+
return `# ${id}
|
|
1187
|
+
`;
|
|
1188
|
+
}
|
|
1189
|
+
var extensionFrontmatterSchema = z.object({ "schema": z.literal("agentproto/extension/v1").describe("Pins the spec version this manifest conforms to."), "slug": z.string().regex(new RegExp("^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*[a-z0-9]$")).describe("Namespaced identifier \u2014 `<namespace>:<name>`. Lowercase, digits, dashes; single colon separator. Example: `acme:deal`."), "title": z.string().min(1).max(80).describe("Human-readable display name."), "description": z.string().min(1).max(2e3).describe("One-paragraph statement of the extension's purpose."), "version": z.string().regex(new RegExp("^\\d+\\.\\d+\\.\\d+(?:[-+][a-zA-Z0-9.-]+)?$")).describe("Extension's own semver. Bump on breaking change."), "status": z.literal("Local").describe("Extensions are perpetually Local \u2014 they never enter the public registry's Draft/Review/Final lifecycle."), "extends": z.union([z.string().regex(new RegExp("^aip-\\d+$")).describe("Inherit a public AIP's schema (e.g. `aip-14`)."), z.literal("none").describe("Brand-new doctype, no inheritance.")]).describe("Parent AIP this extension inherits from, or `none` for a root doctype."), "add_fields": z.object({ "properties": z.record(z.string(), z.any()).describe("JSON Schema property definitions to merge into the parent's properties. Collisions are an error \u2014 use `tighten` to narrow an existing field.").optional(), "required": z.array(z.string()).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), { message: "All items must be unique!" }).describe("Field names that become required in addition to parent's required[]. Unioned with parent's required, never replaces.").default([]) }).strict().describe("Schema-level additions: new properties + new required entries.").optional(), "tighten": z.record(z.string(), z.object({ "pattern": z.string().optional(), "enum": z.array(z.any()).optional(), "minLength": z.number().int().gte(0).optional(), "maxLength": z.number().int().gte(0).optional(), "minimum": z.number().optional(), "maximum": z.number().optional() }).strict()).describe("Per-field constraint overrides. Each entry MUST tighten (not loosen) the parent's constraints; runtimes verify monotonicity at registration.").optional(), "defaults": z.record(z.string(), z.any()).describe("Default values applied when a field is omitted. Layered on top of parent's defaults; extension's value wins on the same key.").optional(), "path_convention": z.string().min(1).describe("Filesystem convention template, e.g. `\"deals/<slug>/DEAL.md\"`. Tokens: `<slug>` (doctype identity), `<DOCTYPE>` (extension's slug name part). Falls back to parent's convention when omitted.").optional(), "requires": z.array(z.number().int().gte(1)).refine((arr) => arr.every((item, i) => arr.indexOf(item) == i), { message: "All items must be unique!" }).describe("Public AIPs the extension depends on, in addition to its parent.").default([]), "metadata": z.record(z.string(), z.any()).describe("Free-form vendor extensions under namespaced keys (`metadata.<vendor>.<field>`).").default({}) }).strict().describe("Validates the YAML frontmatter portion of an AIP-40 EXTENSION.md manifest. An extension declares a workspace-local doctype that inherits a public AIP's schema and may add fields, tighten constraints, set defaults, and override the path convention.");
|
|
1190
|
+
createDoctype({
|
|
1191
|
+
aip: 40,
|
|
1192
|
+
name: "extension",
|
|
1193
|
+
// Extension slugs are namespaced: `<namespace>:<name>` (e.g.
|
|
1194
|
+
// `acme:deal`). Override the default kebab-only pattern.
|
|
1195
|
+
idPattern: /^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*[a-z0-9]$/,
|
|
1196
|
+
readIdentity: (def) => def.slug,
|
|
1197
|
+
validate(def) {
|
|
1198
|
+
const result = extensionFrontmatterSchema.safeParse(def);
|
|
1199
|
+
if (!result.success) {
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
`defineExtension (AIP-40): ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
},
|
|
1205
|
+
build(def) {
|
|
1206
|
+
return { ...def };
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
function parseExtensionManifest(source) {
|
|
1210
|
+
const parsed = matter(source);
|
|
1211
|
+
if (Object.keys(parsed.data).length === 0) {
|
|
1212
|
+
throw new Error("parseExtensionManifest: missing or empty frontmatter");
|
|
1213
|
+
}
|
|
1214
|
+
const result = extensionFrontmatterSchema.safeParse(parsed.data);
|
|
1215
|
+
if (!result.success) {
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`parseExtensionManifest: invalid frontmatter \u2014 ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
return { frontmatter: result.data, body: parsed.content };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ../extension/dist/index.mjs
|
|
1224
|
+
function specFromExtension(extension, opts = {}) {
|
|
1225
|
+
const { parent } = opts;
|
|
1226
|
+
const isRoot = extension.extends === "none";
|
|
1227
|
+
if (!isRoot && !parent) {
|
|
1228
|
+
throw new Error(
|
|
1229
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' extends '${extension.extends}' but no parent spec was provided in opts.parent`
|
|
1230
|
+
);
|
|
1231
|
+
}
|
|
1232
|
+
if (isRoot && !extension.path_convention) {
|
|
1233
|
+
throw new Error(
|
|
1234
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype (extends: none) and MUST declare path_convention`
|
|
1235
|
+
);
|
|
1236
|
+
}
|
|
1237
|
+
if (parent && extension.tighten) {
|
|
1238
|
+
verifyTightening(extension);
|
|
1239
|
+
}
|
|
1240
|
+
const slugName = extension.slug.split(":")[1] ?? extension.slug;
|
|
1241
|
+
const pathTemplate = extension.path_convention ?? null;
|
|
1242
|
+
const extensionKeys = new Set(
|
|
1243
|
+
Object.keys(extension.add_fields?.properties ?? {})
|
|
1244
|
+
);
|
|
1245
|
+
const define = (params) => {
|
|
1246
|
+
const withDefaults = { ...params };
|
|
1247
|
+
if (extension.defaults) {
|
|
1248
|
+
for (const [key, value] of Object.entries(extension.defaults)) {
|
|
1249
|
+
if (withDefaults[key] === void 0 && value !== void 0) {
|
|
1250
|
+
withDefaults[key] = value;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (isRoot) {
|
|
1255
|
+
return Object.freeze(withDefaults);
|
|
1256
|
+
}
|
|
1257
|
+
const parentOnly = {};
|
|
1258
|
+
const extensionOnly = {};
|
|
1259
|
+
for (const [key, value] of Object.entries(withDefaults)) {
|
|
1260
|
+
if (extensionKeys.has(key)) extensionOnly[key] = value;
|
|
1261
|
+
else parentOnly[key] = value;
|
|
1262
|
+
}
|
|
1263
|
+
const parentHandle = parent.define(parentOnly);
|
|
1264
|
+
return Object.freeze({
|
|
1265
|
+
...parentHandle,
|
|
1266
|
+
...extensionOnly
|
|
1267
|
+
});
|
|
1268
|
+
};
|
|
1269
|
+
const parse = isRoot ? rootParse(extension) : parent.parse;
|
|
1270
|
+
const pathOf = (handle) => {
|
|
1271
|
+
if (pathTemplate) {
|
|
1272
|
+
return resolvePathTemplate(
|
|
1273
|
+
pathTemplate,
|
|
1274
|
+
handle,
|
|
1275
|
+
slugName
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
return parent.pathOf(handle);
|
|
1279
|
+
};
|
|
1280
|
+
return {
|
|
1281
|
+
name: extension.slug,
|
|
1282
|
+
aip: 40,
|
|
1283
|
+
schemaLiteral: parent?.schemaLiteral ?? `agentproto/extension/v1`,
|
|
1284
|
+
pathOf,
|
|
1285
|
+
define,
|
|
1286
|
+
parse
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
function verifyTightening(extension, parent) {
|
|
1290
|
+
const t = extension.tighten ?? {};
|
|
1291
|
+
for (const [field, override] of Object.entries(t)) {
|
|
1292
|
+
if (typeof override.minLength === "number" && typeof override.maxLength === "number" && override.minLength > override.maxLength) {
|
|
1293
|
+
throw new Error(
|
|
1294
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minLength (${override.minLength}) > maxLength (${override.maxLength})`
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
if (typeof override.minimum === "number" && typeof override.maximum === "number" && override.minimum > override.maximum) {
|
|
1298
|
+
throw new Error(
|
|
1299
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: minimum (${override.minimum}) > maximum (${override.maximum})`
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
if (override.enum !== void 0 && !Array.isArray(override.enum)) {
|
|
1303
|
+
throw new Error(
|
|
1304
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' tighten.${field}: enum must be an array`
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function rootParse(extension) {
|
|
1310
|
+
return (source) => {
|
|
1311
|
+
throw new Error(
|
|
1312
|
+
`specFromExtension (AIP-40): extension '${extension.slug}' is a root doctype \u2014 supply your own parser or extend a public AIP for parser inheritance`
|
|
1313
|
+
);
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
function resolvePathTemplate(template, handle, doctypeSlug) {
|
|
1317
|
+
const identity = typeof handle.id === "string" && handle.id || typeof handle.slug === "string" && handle.slug || typeof handle.name === "string" && handle.name || "unknown";
|
|
1318
|
+
return template.replace(/<slug>/g, identity).replace(/<DOCTYPE>/g, doctypeSlug.toUpperCase());
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ../mcp-server/dist/index.mjs
|
|
1322
|
+
async function createMcpServer(opts) {
|
|
1323
|
+
const server = new McpServer({
|
|
1324
|
+
name: opts.name ?? "agentproto-mcp-server",
|
|
1325
|
+
version: opts.version ?? "0.1.0-alpha"
|
|
1326
|
+
});
|
|
1327
|
+
const allSpecs = [...opts.specs];
|
|
1328
|
+
if (opts.workspace) {
|
|
1329
|
+
const extensions = await loadExtensions(opts.workspace, opts.specs);
|
|
1330
|
+
allSpecs.push(...extensions);
|
|
1331
|
+
}
|
|
1332
|
+
const anchor = (p) => isAbsolute(p) || !opts.workspace ? p : join(opts.workspace, p);
|
|
1333
|
+
for (const spec of allSpecs) {
|
|
1334
|
+
registerVerbs(server, spec, anchor);
|
|
1335
|
+
}
|
|
1336
|
+
return {
|
|
1337
|
+
server,
|
|
1338
|
+
registered: allSpecs.map((s) => s.name)
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
function registerVerbs(server, spec, anchor) {
|
|
1342
|
+
const verbs = createVerbs(spec);
|
|
1343
|
+
const verbName = (verb) => `${verb}_${spec.name.replace(/[-:]/g, "_")}`;
|
|
1344
|
+
const description = (verb, body) => `${verb} the AIP-${spec.aip} ${spec.name} doctype. ${body}`;
|
|
1345
|
+
server.tool(
|
|
1346
|
+
verbName("create"),
|
|
1347
|
+
description(
|
|
1348
|
+
"create",
|
|
1349
|
+
"Author a new manifest from params. Returns { path, rendered }."
|
|
1350
|
+
),
|
|
1351
|
+
{
|
|
1352
|
+
params: z.record(z.string(), z.unknown()).describe(`The ${spec.name} definition fields.`),
|
|
1353
|
+
dir: z.string().describe("Workspace-relative or absolute target directory."),
|
|
1354
|
+
body: z.string().optional().describe("Markdown body after the frontmatter. Defaults to a stub."),
|
|
1355
|
+
dryRun: z.boolean().optional().describe("Render only \u2014 don't write to disk.")
|
|
1356
|
+
},
|
|
1357
|
+
async ({ params, dir, body, dryRun }) => {
|
|
1358
|
+
const result = await verbs.create(params, {
|
|
1359
|
+
dir: anchor(dir),
|
|
1360
|
+
body,
|
|
1361
|
+
dryRun
|
|
1362
|
+
});
|
|
1363
|
+
return contentText({
|
|
1364
|
+
path: result.path,
|
|
1365
|
+
rendered: result.rendered
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
);
|
|
1369
|
+
server.tool(
|
|
1370
|
+
verbName("load"),
|
|
1371
|
+
description("load", "Read a manifest from disk. Returns the parsed handle."),
|
|
1372
|
+
{
|
|
1373
|
+
path: z.string().describe("Absolute or workspace-relative path to the .md file.")
|
|
1374
|
+
},
|
|
1375
|
+
async ({ path }) => {
|
|
1376
|
+
const result = await verbs.load(anchor(path));
|
|
1377
|
+
return contentText({ path: result.path, handle: result.handle });
|
|
1378
|
+
}
|
|
1379
|
+
);
|
|
1380
|
+
server.tool(
|
|
1381
|
+
verbName("list"),
|
|
1382
|
+
description(
|
|
1383
|
+
"list",
|
|
1384
|
+
"Walk a directory tree and return all manifests of this doctype."
|
|
1385
|
+
),
|
|
1386
|
+
{
|
|
1387
|
+
dir: z.string().describe("Directory to walk."),
|
|
1388
|
+
skipDirs: z.array(z.string()).optional().describe("Subdir names to skip (default: node_modules, .git, dist).")
|
|
1389
|
+
},
|
|
1390
|
+
async ({ dir, skipDirs }) => {
|
|
1391
|
+
const handles = await verbs.list(anchor(dir), { skipDirs });
|
|
1392
|
+
return contentText({ count: handles.length, handles });
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
server.tool(
|
|
1396
|
+
verbName("update"),
|
|
1397
|
+
description(
|
|
1398
|
+
"update",
|
|
1399
|
+
"Patch an existing manifest. The patch is shallow-merged into the existing params."
|
|
1400
|
+
),
|
|
1401
|
+
{
|
|
1402
|
+
path: z.string().describe("Path to the manifest to update."),
|
|
1403
|
+
patch: z.record(z.string(), z.unknown()).describe("Partial fields to merge into the existing manifest."),
|
|
1404
|
+
body: z.string().optional().describe("New body markdown (default: keep existing).")
|
|
1405
|
+
},
|
|
1406
|
+
async ({ path, patch, body }) => {
|
|
1407
|
+
const result = await verbs.update(
|
|
1408
|
+
anchor(path),
|
|
1409
|
+
(existing) => ({ ...existing, ...patch }),
|
|
1410
|
+
{ body }
|
|
1411
|
+
);
|
|
1412
|
+
return contentText({ path: result.path, rendered: result.rendered });
|
|
1413
|
+
}
|
|
1414
|
+
);
|
|
1415
|
+
server.tool(
|
|
1416
|
+
verbName("resolve"),
|
|
1417
|
+
description(
|
|
1418
|
+
"resolve",
|
|
1419
|
+
"Resolve an inline | ref | file block to a fully-typed handle."
|
|
1420
|
+
),
|
|
1421
|
+
{
|
|
1422
|
+
block: z.union([
|
|
1423
|
+
z.object({ inline: z.record(z.string(), z.unknown()) }),
|
|
1424
|
+
z.object({ ref: z.string() }),
|
|
1425
|
+
z.object({ file: z.string() })
|
|
1426
|
+
]).describe("The composition block."),
|
|
1427
|
+
baseDir: z.string().optional().describe("Base dir for `file:` references.")
|
|
1428
|
+
},
|
|
1429
|
+
async ({ block, baseDir }) => {
|
|
1430
|
+
const handle = await verbs.resolve(block, {
|
|
1431
|
+
baseDir: baseDir ? anchor(baseDir) : void 0
|
|
1432
|
+
});
|
|
1433
|
+
return contentText({ handle });
|
|
1434
|
+
}
|
|
1435
|
+
);
|
|
1436
|
+
server.tool(
|
|
1437
|
+
verbName("delete"),
|
|
1438
|
+
description("delete", "Remove a manifest file from disk."),
|
|
1439
|
+
{
|
|
1440
|
+
path: z.string().describe("Path to the manifest to delete.")
|
|
1441
|
+
},
|
|
1442
|
+
async ({ path }) => {
|
|
1443
|
+
const target = anchor(path);
|
|
1444
|
+
await verbs.delete(target);
|
|
1445
|
+
return contentText({ deleted: target });
|
|
1446
|
+
}
|
|
1447
|
+
);
|
|
1448
|
+
}
|
|
1449
|
+
async function loadExtensions(workspace, specs) {
|
|
1450
|
+
const extDir = resolve(workspace, "extensions");
|
|
1451
|
+
if (!existsSync(extDir)) return [];
|
|
1452
|
+
const out = [];
|
|
1453
|
+
const { readdir: readdir3 } = await import('fs/promises');
|
|
1454
|
+
let entries;
|
|
1455
|
+
try {
|
|
1456
|
+
entries = await readdir3(extDir, {
|
|
1457
|
+
withFileTypes: true
|
|
1458
|
+
});
|
|
1459
|
+
} catch {
|
|
1460
|
+
return out;
|
|
1461
|
+
}
|
|
1462
|
+
for (const entry of entries) {
|
|
1463
|
+
if (!entry.isDirectory()) continue;
|
|
1464
|
+
const manifestPath = join(extDir, String(entry.name), "EXTENSION.md");
|
|
1465
|
+
if (!existsSync(manifestPath)) continue;
|
|
1466
|
+
const source = await readFile(manifestPath, "utf8");
|
|
1467
|
+
const parsed = parseExtensionManifest(source);
|
|
1468
|
+
const ext = parsed.frontmatter;
|
|
1469
|
+
let parent;
|
|
1470
|
+
if (ext.extends && ext.extends !== "none") {
|
|
1471
|
+
const aipMatch = ext.extends.match(/^aip-(\d+)$/);
|
|
1472
|
+
if (!aipMatch) {
|
|
1473
|
+
throw new Error(
|
|
1474
|
+
`mcp-server: extension '${ext.slug}' has invalid extends '${ext.extends}'`
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
const parentAip = Number(aipMatch[1]);
|
|
1478
|
+
parent = specs.find((s) => s.aip === parentAip);
|
|
1479
|
+
if (!parent) {
|
|
1480
|
+
throw new Error(
|
|
1481
|
+
`mcp-server: extension '${ext.slug}' extends aip-${parentAip}, but no spec for that AIP was registered. Pass the parent spec in opts.specs.`
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
out.push(specFromExtension(ext, { parent }));
|
|
1486
|
+
}
|
|
1487
|
+
return out;
|
|
1488
|
+
}
|
|
1489
|
+
function contentText(payload) {
|
|
1490
|
+
return {
|
|
1491
|
+
content: [
|
|
1492
|
+
{
|
|
1493
|
+
type: "text",
|
|
1494
|
+
text: JSON.stringify(payload, null, 2)
|
|
1495
|
+
}
|
|
1496
|
+
]
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
async function writeRuntimeMeta(workspace, meta) {
|
|
1500
|
+
const dir = join(workspace, ".agentproto");
|
|
1501
|
+
try {
|
|
1502
|
+
await mkdir(dir, { recursive: true });
|
|
1503
|
+
await writeFile(
|
|
1504
|
+
join(dir, "runtime.json"),
|
|
1505
|
+
JSON.stringify(meta, null, 2) + "\n",
|
|
1506
|
+
"utf8"
|
|
1507
|
+
);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
console.error("[runtime] failed to write .agentproto/runtime.json:", err);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
1513
|
+
var MAX_TIMEOUT_MS = 6e5;
|
|
1514
|
+
var ALLOWLIST_REL = ".agentproto/allowed-commands.json";
|
|
1515
|
+
var allowlistCache = null;
|
|
1516
|
+
async function loadAllowlist(workspace) {
|
|
1517
|
+
const path = resolve(workspace, ALLOWLIST_REL);
|
|
1518
|
+
if (!existsSync(path)) {
|
|
1519
|
+
allowlistCache = null;
|
|
1520
|
+
return /* @__PURE__ */ new Set();
|
|
1521
|
+
}
|
|
1522
|
+
try {
|
|
1523
|
+
const s = await stat(path);
|
|
1524
|
+
if (allowlistCache && allowlistCache.path === path && allowlistCache.entry.mtimeMs === s.mtimeMs) {
|
|
1525
|
+
return allowlistCache.entry.commands;
|
|
1526
|
+
}
|
|
1527
|
+
const raw = await readFile(path, "utf8");
|
|
1528
|
+
const parsed = JSON.parse(raw);
|
|
1529
|
+
const list = Array.isArray(parsed.commands) ? parsed.commands : [];
|
|
1530
|
+
const commands = new Set(
|
|
1531
|
+
list.filter((x) => typeof x === "string" && x.length > 0).map((x) => x.trim())
|
|
1532
|
+
);
|
|
1533
|
+
allowlistCache = { path, entry: { mtimeMs: s.mtimeMs, commands } };
|
|
1534
|
+
return commands;
|
|
1535
|
+
} catch (err) {
|
|
1536
|
+
console.error(
|
|
1537
|
+
`[runtime] failed to load ${ALLOWLIST_REL} (will deny all):`,
|
|
1538
|
+
err
|
|
1539
|
+
);
|
|
1540
|
+
allowlistCache = null;
|
|
1541
|
+
return /* @__PURE__ */ new Set();
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
function makeCwdAnchor(workspace) {
|
|
1545
|
+
const root = resolve(workspace);
|
|
1546
|
+
return (input2) => {
|
|
1547
|
+
if (!input2 || input2.length === 0) return root;
|
|
1548
|
+
const candidate = isAbsolute(input2) ? normalize(input2) : normalize(join(root, input2));
|
|
1549
|
+
const rel = relative(root, candidate);
|
|
1550
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
1551
|
+
throw new Error(
|
|
1552
|
+
`cwd escapes the workspace: '${input2}' (workspace=${root})`
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
return candidate;
|
|
1556
|
+
};
|
|
1557
|
+
}
|
|
1558
|
+
function registerCommandTools(server, opts) {
|
|
1559
|
+
const anchorCwd = makeCwdAnchor(opts.workspace);
|
|
1560
|
+
server.tool(
|
|
1561
|
+
"execute_command",
|
|
1562
|
+
"Run a shell command on the host running the runtime. The command basename must be in `<workspace>/.agentproto/allowed-commands.json`; default-deny otherwise. Captures stdout / stderr / exit code and returns them as JSON. Use this to drive local CLIs (Claude Code, gh, pnpm, \u2026) from a remote agent.",
|
|
1563
|
+
{
|
|
1564
|
+
command: z.string().min(1).describe(
|
|
1565
|
+
"Executable name or absolute path. Must be allowlisted by basename in the workspace's allowed-commands.json."
|
|
1566
|
+
),
|
|
1567
|
+
args: z.array(z.string()).optional().describe(
|
|
1568
|
+
"Argv array. Passed verbatim \u2014 no shell expansion (we spawn with shell:false so quoting doesn't bite)."
|
|
1569
|
+
),
|
|
1570
|
+
cwd: z.string().optional().describe(
|
|
1571
|
+
"Working directory, workspace-relative or an absolute path inside the workspace. Defaults to the workspace root."
|
|
1572
|
+
),
|
|
1573
|
+
stdin: z.string().optional().describe("Optional input piped to the process's stdin."),
|
|
1574
|
+
timeoutMs: z.number().int().positive().max(MAX_TIMEOUT_MS).optional().describe(
|
|
1575
|
+
`Hard kill after this many ms. Defaults to ${DEFAULT_TIMEOUT_MS}; capped at ${MAX_TIMEOUT_MS}.`
|
|
1576
|
+
)
|
|
1577
|
+
},
|
|
1578
|
+
async ({ command, args, cwd, stdin, timeoutMs }) => {
|
|
1579
|
+
const allowlist = await loadAllowlist(opts.workspace);
|
|
1580
|
+
const baseName = basename(command);
|
|
1581
|
+
if (!allowlist.has(baseName)) {
|
|
1582
|
+
const allowed = [...allowlist].sort().join(", ") || "(empty)";
|
|
1583
|
+
throw new Error(
|
|
1584
|
+
`command '${baseName}' is not in the allowlist. Add it to ${join(opts.workspace, ALLOWLIST_REL)} under "commands": [...]. Currently allowed: ${allowed}.`
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
const resolvedCwd = anchorCwd(cwd);
|
|
1588
|
+
const limit = Math.min(timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
1589
|
+
const result = await runCommand({
|
|
1590
|
+
command,
|
|
1591
|
+
args: args ?? [],
|
|
1592
|
+
cwd: resolvedCwd,
|
|
1593
|
+
stdin,
|
|
1594
|
+
timeoutMs: limit
|
|
1595
|
+
});
|
|
1596
|
+
return {
|
|
1597
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
var STREAM_BUFFER_CAP = 1048576;
|
|
1603
|
+
async function runCommand(input2) {
|
|
1604
|
+
return new Promise((resolvePromise) => {
|
|
1605
|
+
const startedAt = Date.now();
|
|
1606
|
+
const child = spawn(input2.command, input2.args, {
|
|
1607
|
+
cwd: input2.cwd,
|
|
1608
|
+
shell: false,
|
|
1609
|
+
// Inherit user env so PATH lookups for `claude`, `gh`, etc. work.
|
|
1610
|
+
env: process.env,
|
|
1611
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1612
|
+
});
|
|
1613
|
+
let stdout = "";
|
|
1614
|
+
let stderr = "";
|
|
1615
|
+
let truncated = false;
|
|
1616
|
+
let timedOut = false;
|
|
1617
|
+
function appendCapped(buf, chunk) {
|
|
1618
|
+
if (buf.length >= STREAM_BUFFER_CAP) {
|
|
1619
|
+
truncated = true;
|
|
1620
|
+
return buf;
|
|
1621
|
+
}
|
|
1622
|
+
const room = STREAM_BUFFER_CAP - buf.length;
|
|
1623
|
+
const text3 = chunk.toString("utf8");
|
|
1624
|
+
if (text3.length > room) {
|
|
1625
|
+
truncated = true;
|
|
1626
|
+
return buf + text3.slice(0, room);
|
|
1627
|
+
}
|
|
1628
|
+
return buf + text3;
|
|
1629
|
+
}
|
|
1630
|
+
child.stdout?.on("data", (d) => {
|
|
1631
|
+
stdout = appendCapped(stdout, d);
|
|
1632
|
+
});
|
|
1633
|
+
child.stderr?.on("data", (d) => {
|
|
1634
|
+
stderr = appendCapped(stderr, d);
|
|
1635
|
+
});
|
|
1636
|
+
if (input2.stdin) {
|
|
1637
|
+
child.stdin?.write(input2.stdin);
|
|
1638
|
+
}
|
|
1639
|
+
child.stdin?.end();
|
|
1640
|
+
const timer = setTimeout(() => {
|
|
1641
|
+
timedOut = true;
|
|
1642
|
+
try {
|
|
1643
|
+
child.kill("SIGTERM");
|
|
1644
|
+
} catch {
|
|
1645
|
+
}
|
|
1646
|
+
setTimeout(() => {
|
|
1647
|
+
try {
|
|
1648
|
+
child.kill("SIGKILL");
|
|
1649
|
+
} catch {
|
|
1650
|
+
}
|
|
1651
|
+
}, 2e3).unref();
|
|
1652
|
+
}, input2.timeoutMs);
|
|
1653
|
+
timer.unref();
|
|
1654
|
+
child.on("error", (err) => {
|
|
1655
|
+
clearTimeout(timer);
|
|
1656
|
+
resolvePromise({
|
|
1657
|
+
exitCode: -1,
|
|
1658
|
+
signal: null,
|
|
1659
|
+
stdout,
|
|
1660
|
+
stderr: stderr + (stderr ? "\n" : "") + err.message,
|
|
1661
|
+
truncated,
|
|
1662
|
+
durationMs: Date.now() - startedAt
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
child.on("close", (code, signal) => {
|
|
1666
|
+
clearTimeout(timer);
|
|
1667
|
+
resolvePromise({
|
|
1668
|
+
exitCode: typeof code === "number" ? code : -1,
|
|
1669
|
+
signal: signal ?? (timedOut ? "SIGTERM-timeout" : null),
|
|
1670
|
+
stdout,
|
|
1671
|
+
stderr,
|
|
1672
|
+
truncated,
|
|
1673
|
+
durationMs: Date.now() - startedAt
|
|
1674
|
+
});
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
var FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/;
|
|
1679
|
+
function fileConversationStore(opts) {
|
|
1680
|
+
const dir = opts.dir ?? join(opts.workspace, "conversations");
|
|
1681
|
+
const filePath = (id) => join(dir, `${id}.md`);
|
|
1682
|
+
return {
|
|
1683
|
+
pathFor: filePath,
|
|
1684
|
+
async open(id, meta) {
|
|
1685
|
+
await mkdir(dir, { recursive: true });
|
|
1686
|
+
const path = filePath(id);
|
|
1687
|
+
if (existsSync(path)) return;
|
|
1688
|
+
const header = renderHeader({
|
|
1689
|
+
id,
|
|
1690
|
+
agent: meta.agent,
|
|
1691
|
+
started: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1692
|
+
status: "open"
|
|
1693
|
+
});
|
|
1694
|
+
await writeFile(path, header, "utf8");
|
|
1695
|
+
},
|
|
1696
|
+
async appendTurn(id, role, content, options) {
|
|
1697
|
+
const path = filePath(id);
|
|
1698
|
+
if (!existsSync(path)) {
|
|
1699
|
+
await this.open(id, { agent: options?.attribution ?? "unknown" });
|
|
1700
|
+
}
|
|
1701
|
+
const block = renderTurn({
|
|
1702
|
+
role,
|
|
1703
|
+
at: options?.at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1704
|
+
attribution: options?.attribution,
|
|
1705
|
+
content
|
|
1706
|
+
});
|
|
1707
|
+
await writeFile(path, block, { encoding: "utf8", flag: "a" });
|
|
1708
|
+
},
|
|
1709
|
+
async read(id) {
|
|
1710
|
+
const path = filePath(id);
|
|
1711
|
+
const source = await readFile(path, "utf8");
|
|
1712
|
+
return parseConversation(source, id);
|
|
1713
|
+
},
|
|
1714
|
+
async list() {
|
|
1715
|
+
if (!existsSync(dir)) return [];
|
|
1716
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1717
|
+
const out = [];
|
|
1718
|
+
for (const entry of entries) {
|
|
1719
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
1720
|
+
const id = entry.name.slice(0, -3);
|
|
1721
|
+
try {
|
|
1722
|
+
const source = await readFile(join(dir, entry.name), "utf8");
|
|
1723
|
+
const { meta } = parseConversation(source, id);
|
|
1724
|
+
out.push({ id, meta });
|
|
1725
|
+
} catch {
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return out;
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
function renderHeader(meta) {
|
|
1733
|
+
return [
|
|
1734
|
+
"---",
|
|
1735
|
+
`schema: conversation/v1`,
|
|
1736
|
+
`id: ${meta.id}`,
|
|
1737
|
+
`agent: ${meta.agent}`,
|
|
1738
|
+
`started: ${meta.started}`,
|
|
1739
|
+
`status: ${meta.status}`,
|
|
1740
|
+
"---",
|
|
1741
|
+
"",
|
|
1742
|
+
""
|
|
1743
|
+
].join("\n");
|
|
1744
|
+
}
|
|
1745
|
+
function renderTurn(turn) {
|
|
1746
|
+
const heading = turn.attribution ? `## ${turn.role} \u2014 ${turn.at} (${turn.attribution})` : `## ${turn.role} \u2014 ${turn.at}`;
|
|
1747
|
+
return `${heading}
|
|
1748
|
+
|
|
1749
|
+
${turn.content.trimEnd()}
|
|
1750
|
+
|
|
1751
|
+
`;
|
|
1752
|
+
}
|
|
1753
|
+
function parseConversation(source, fallbackId) {
|
|
1754
|
+
const match = source.match(FRONTMATTER_RE);
|
|
1755
|
+
let meta = {
|
|
1756
|
+
id: fallbackId,
|
|
1757
|
+
agent: "unknown",
|
|
1758
|
+
started: "",
|
|
1759
|
+
status: "open"
|
|
1760
|
+
};
|
|
1761
|
+
let body = source;
|
|
1762
|
+
if (match) {
|
|
1763
|
+
meta = parseFrontmatter(match[1] ?? "", fallbackId);
|
|
1764
|
+
body = source.slice(match[0].length);
|
|
1765
|
+
}
|
|
1766
|
+
const turns = [];
|
|
1767
|
+
const lines = body.split("\n");
|
|
1768
|
+
let current = null;
|
|
1769
|
+
let buffer = [];
|
|
1770
|
+
const flush = () => {
|
|
1771
|
+
if (!current) return;
|
|
1772
|
+
current.content = buffer.join("\n").trim();
|
|
1773
|
+
turns.push(current);
|
|
1774
|
+
current = null;
|
|
1775
|
+
buffer = [];
|
|
1776
|
+
};
|
|
1777
|
+
const headingRe = /^##\s+(user|assistant|system)\s+—\s+(\S+)(?:\s+\(([^)]+)\))?\s*$/;
|
|
1778
|
+
for (const line of lines) {
|
|
1779
|
+
const m = line.match(headingRe);
|
|
1780
|
+
if (m) {
|
|
1781
|
+
flush();
|
|
1782
|
+
current = {
|
|
1783
|
+
role: m[1],
|
|
1784
|
+
at: m[2] ?? "",
|
|
1785
|
+
attribution: m[3],
|
|
1786
|
+
content: ""
|
|
1787
|
+
};
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
if (current) buffer.push(line);
|
|
1791
|
+
}
|
|
1792
|
+
flush();
|
|
1793
|
+
return { meta, turns };
|
|
1794
|
+
}
|
|
1795
|
+
function parseFrontmatter(raw, fallbackId) {
|
|
1796
|
+
const out = {};
|
|
1797
|
+
for (const line of raw.split("\n")) {
|
|
1798
|
+
const colon = line.indexOf(":");
|
|
1799
|
+
if (colon < 1) continue;
|
|
1800
|
+
const key = line.slice(0, colon).trim();
|
|
1801
|
+
const value = line.slice(colon + 1).trim();
|
|
1802
|
+
out[key] = value;
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
id: out.id ?? fallbackId,
|
|
1806
|
+
agent: out.agent ?? "unknown",
|
|
1807
|
+
started: out.started ?? "",
|
|
1808
|
+
status: out.status === "closed" ? "closed" : "open"
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
function createRuntimeEvents() {
|
|
1812
|
+
const ee = new EventEmitter();
|
|
1813
|
+
ee.setMaxListeners(50);
|
|
1814
|
+
return {
|
|
1815
|
+
on(type, handler) {
|
|
1816
|
+
const wrapped = (ev) => {
|
|
1817
|
+
if (ev.type === type) handler(ev);
|
|
1818
|
+
};
|
|
1819
|
+
ee.on("event", wrapped);
|
|
1820
|
+
return () => ee.off("event", wrapped);
|
|
1821
|
+
},
|
|
1822
|
+
onAny(handler) {
|
|
1823
|
+
ee.on("event", handler);
|
|
1824
|
+
return () => ee.off("event", handler);
|
|
1825
|
+
},
|
|
1826
|
+
emit(ev) {
|
|
1827
|
+
ee.emit("event", ev);
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
var FsPathError = class extends Error {
|
|
1832
|
+
constructor(message) {
|
|
1833
|
+
super(message);
|
|
1834
|
+
this.name = "FsPathError";
|
|
1835
|
+
}
|
|
1836
|
+
};
|
|
1837
|
+
function makeAnchor(workspace) {
|
|
1838
|
+
const root = resolve(workspace);
|
|
1839
|
+
return (input2) => {
|
|
1840
|
+
if (typeof input2 !== "string" || input2.length === 0) {
|
|
1841
|
+
throw new FsPathError("path must be a non-empty string");
|
|
1842
|
+
}
|
|
1843
|
+
const candidate = isAbsolute(input2) ? normalize(input2) : normalize(join(root, input2));
|
|
1844
|
+
const rel = relative(root, candidate);
|
|
1845
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
1846
|
+
throw new FsPathError(`path escapes the workspace: '${input2}'`);
|
|
1847
|
+
}
|
|
1848
|
+
return candidate;
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
function text(value) {
|
|
1852
|
+
return {
|
|
1853
|
+
content: [
|
|
1854
|
+
{
|
|
1855
|
+
type: "text",
|
|
1856
|
+
text: typeof value === "string" ? value : JSON.stringify(value)
|
|
1857
|
+
}
|
|
1858
|
+
]
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
function registerFsTools(server, opts) {
|
|
1862
|
+
const anchor = makeAnchor(opts.workspace);
|
|
1863
|
+
server.tool(
|
|
1864
|
+
"read_file",
|
|
1865
|
+
"Read a UTF-8 file from the workspace.",
|
|
1866
|
+
{ path: z.string().describe("Workspace-relative path to the file.") },
|
|
1867
|
+
async ({ path }) => {
|
|
1868
|
+
const abs = anchor(path);
|
|
1869
|
+
const buf = await readFile(abs);
|
|
1870
|
+
return text(buf.toString("utf8"));
|
|
1871
|
+
}
|
|
1872
|
+
);
|
|
1873
|
+
server.tool(
|
|
1874
|
+
"write_file",
|
|
1875
|
+
"Write a file to the workspace. Parent directories are created on demand.",
|
|
1876
|
+
{
|
|
1877
|
+
path: z.string().describe("Workspace-relative path to the file."),
|
|
1878
|
+
content: z.string().describe("File body (UTF-8).")
|
|
1879
|
+
},
|
|
1880
|
+
async ({ path, content }) => {
|
|
1881
|
+
const abs = anchor(path);
|
|
1882
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
1883
|
+
await writeFile(abs, content, "utf8");
|
|
1884
|
+
return text("ok");
|
|
1885
|
+
}
|
|
1886
|
+
);
|
|
1887
|
+
server.tool(
|
|
1888
|
+
"list_directory",
|
|
1889
|
+
"List entries of a directory in the workspace. Returns one '[FILE]' or '[DIR]' line per entry, mirroring `@modelcontextprotocol/server-filesystem`.",
|
|
1890
|
+
{
|
|
1891
|
+
path: z.string().optional().describe("Workspace-relative directory (defaults to workspace root).")
|
|
1892
|
+
},
|
|
1893
|
+
async ({ path }) => {
|
|
1894
|
+
const abs = anchor(path && path.length > 0 ? path : ".");
|
|
1895
|
+
const entries = await readdir(abs, { withFileTypes: true });
|
|
1896
|
+
const lines = entries.map(
|
|
1897
|
+
(e) => e.isDirectory() ? `[DIR] ${e.name}` : `[FILE] ${e.name}`
|
|
1898
|
+
);
|
|
1899
|
+
return text(lines.join("\n"));
|
|
1900
|
+
}
|
|
1901
|
+
);
|
|
1902
|
+
server.tool(
|
|
1903
|
+
"get_file_info",
|
|
1904
|
+
"Stat a file or directory in the workspace.",
|
|
1905
|
+
{ path: z.string().describe("Workspace-relative path.") },
|
|
1906
|
+
async ({ path }) => {
|
|
1907
|
+
const abs = anchor(path);
|
|
1908
|
+
const info = await stat(abs);
|
|
1909
|
+
return text({
|
|
1910
|
+
name: abs.split("/").pop() ?? path,
|
|
1911
|
+
path,
|
|
1912
|
+
type: info.isDirectory() ? "directory" : "file",
|
|
1913
|
+
size: info.size,
|
|
1914
|
+
modified: info.mtime.toISOString(),
|
|
1915
|
+
created: info.birthtime.toISOString()
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
);
|
|
1919
|
+
server.tool(
|
|
1920
|
+
"create_directory",
|
|
1921
|
+
"Create a directory (recursive) in the workspace.",
|
|
1922
|
+
{ path: z.string().describe("Workspace-relative directory path.") },
|
|
1923
|
+
async ({ path }) => {
|
|
1924
|
+
const abs = anchor(path);
|
|
1925
|
+
await mkdir(abs, { recursive: true });
|
|
1926
|
+
return text("ok");
|
|
1927
|
+
}
|
|
1928
|
+
);
|
|
1929
|
+
server.tool(
|
|
1930
|
+
"delete_file",
|
|
1931
|
+
"Delete a file or empty directory in the workspace.",
|
|
1932
|
+
{ path: z.string().describe("Workspace-relative path.") },
|
|
1933
|
+
async ({ path }) => {
|
|
1934
|
+
const abs = anchor(path);
|
|
1935
|
+
await rm(abs, { recursive: true, force: true });
|
|
1936
|
+
return text("ok");
|
|
1937
|
+
}
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
var WORKSPACES_CONFIG_VERSION = 1;
|
|
1941
|
+
var DEFAULT_CONFIG_DIR = () => resolve(homedir(), ".agentproto");
|
|
1942
|
+
var DEFAULT_CONFIG_PATH = () => resolve(DEFAULT_CONFIG_DIR(), "workspaces.json");
|
|
1943
|
+
function sanitizeSlug(input2) {
|
|
1944
|
+
const trimmed = input2.trim().toLowerCase();
|
|
1945
|
+
const cleaned = trimmed.replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1946
|
+
return cleaned || "workspace";
|
|
1947
|
+
}
|
|
1948
|
+
async function loadWorkspacesConfig(path = DEFAULT_CONFIG_PATH()) {
|
|
1949
|
+
let raw;
|
|
1950
|
+
try {
|
|
1951
|
+
raw = await promises.readFile(path, "utf8");
|
|
1952
|
+
} catch (err) {
|
|
1953
|
+
if (err.code === "ENOENT") {
|
|
1954
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
1955
|
+
}
|
|
1956
|
+
throw err;
|
|
1957
|
+
}
|
|
1958
|
+
let parsed;
|
|
1959
|
+
try {
|
|
1960
|
+
parsed = JSON.parse(raw);
|
|
1961
|
+
} catch (err) {
|
|
1962
|
+
throw new Error(
|
|
1963
|
+
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete the file or fix it manually.`
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
return normaliseConfig(parsed);
|
|
1967
|
+
}
|
|
1968
|
+
function findWorkspace(config, slug) {
|
|
1969
|
+
return config.workspaces.find((w) => w.slug === sanitizeSlug(slug));
|
|
1970
|
+
}
|
|
1971
|
+
function getActiveWorkspace(config) {
|
|
1972
|
+
if (!config.active) return config.workspaces[0];
|
|
1973
|
+
return findWorkspace(config, config.active) ?? config.workspaces[0];
|
|
1974
|
+
}
|
|
1975
|
+
function normaliseConfig(parsed) {
|
|
1976
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1977
|
+
return { version: WORKSPACES_CONFIG_VERSION, workspaces: [] };
|
|
1978
|
+
}
|
|
1979
|
+
const obj = parsed;
|
|
1980
|
+
const workspaces = [];
|
|
1981
|
+
if (Array.isArray(obj.workspaces)) {
|
|
1982
|
+
for (const entry of obj.workspaces) {
|
|
1983
|
+
if (!entry || typeof entry !== "object") continue;
|
|
1984
|
+
const e = entry;
|
|
1985
|
+
const slug = typeof e.slug === "string" ? sanitizeSlug(e.slug) : "";
|
|
1986
|
+
const path = typeof e.path === "string" ? e.path : "";
|
|
1987
|
+
if (!slug || !path) continue;
|
|
1988
|
+
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
1989
|
+
const updatedAt = typeof e.updatedAt === "string" ? e.updatedAt : addedAt;
|
|
1990
|
+
const we = { slug, path, addedAt, updatedAt };
|
|
1991
|
+
if (typeof e.label === "string" && e.label.trim()) {
|
|
1992
|
+
we.label = e.label.trim();
|
|
1993
|
+
}
|
|
1994
|
+
workspaces.push(we);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
const dedup = /* @__PURE__ */ new Map();
|
|
1998
|
+
for (const w of workspaces) dedup.set(w.slug, w);
|
|
1999
|
+
const finalList = Array.from(dedup.values());
|
|
2000
|
+
const active = typeof obj.active === "string" && finalList.some((w) => w.slug === obj.active) ? obj.active : finalList[0]?.slug;
|
|
2001
|
+
const out = {
|
|
2002
|
+
version: WORKSPACES_CONFIG_VERSION,
|
|
2003
|
+
workspaces: finalList
|
|
2004
|
+
};
|
|
2005
|
+
if (active !== void 0) out.active = active;
|
|
2006
|
+
return out;
|
|
2007
|
+
}
|
|
2008
|
+
async function discoverMcps(opts = {}) {
|
|
2009
|
+
const home = opts.home ?? homedir();
|
|
2010
|
+
const out = [];
|
|
2011
|
+
const errors = [];
|
|
2012
|
+
await Promise.all([
|
|
2013
|
+
scanClaudeCode(home, out, errors),
|
|
2014
|
+
scanCursor(home, out, errors),
|
|
2015
|
+
scanGoose(home, out),
|
|
2016
|
+
scanRegisteredWorkspaces(out, errors)
|
|
2017
|
+
]);
|
|
2018
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2019
|
+
const dedup = [];
|
|
2020
|
+
for (const m of out) {
|
|
2021
|
+
const key = `${m.source}:${m.scope}:${m.name}`;
|
|
2022
|
+
if (seen.has(key)) continue;
|
|
2023
|
+
seen.add(key);
|
|
2024
|
+
dedup.push(m);
|
|
2025
|
+
}
|
|
2026
|
+
if (errors.length > 0) {
|
|
2027
|
+
for (const e of errors) console.warn(`[mcp-discovery] ${e}`);
|
|
2028
|
+
}
|
|
2029
|
+
return dedup.sort((a, b) => {
|
|
2030
|
+
if (a.source !== b.source) return a.source.localeCompare(b.source);
|
|
2031
|
+
if (a.scope !== b.scope) return a.scope.localeCompare(b.scope);
|
|
2032
|
+
return a.name.localeCompare(b.name);
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
async function scanClaudeCode(home, out, errors) {
|
|
2036
|
+
const path = resolve(home, ".claude.json");
|
|
2037
|
+
let raw;
|
|
2038
|
+
try {
|
|
2039
|
+
raw = await promises.readFile(path, "utf8");
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
if (err.code === "ENOENT") return;
|
|
2042
|
+
errors.push(`claude-code: read ${path} \u2014 ${err.message}`);
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
let parsed;
|
|
2046
|
+
try {
|
|
2047
|
+
parsed = JSON.parse(raw);
|
|
2048
|
+
} catch (err) {
|
|
2049
|
+
errors.push(`claude-code: JSON parse ${path} \u2014 ${err.message}`);
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
2053
|
+
const root = parsed;
|
|
2054
|
+
const top = root.mcpServers;
|
|
2055
|
+
if (top && typeof top === "object") {
|
|
2056
|
+
pushMcpMap({
|
|
2057
|
+
out,
|
|
2058
|
+
source: "claude-code",
|
|
2059
|
+
scope: "global",
|
|
2060
|
+
map: top
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
const projects = root.projects;
|
|
2064
|
+
if (projects && typeof projects === "object") {
|
|
2065
|
+
for (const [projPath, proj] of Object.entries(
|
|
2066
|
+
projects
|
|
2067
|
+
)) {
|
|
2068
|
+
if (!proj || typeof proj !== "object") continue;
|
|
2069
|
+
const projMcps = proj.mcpServers;
|
|
2070
|
+
if (!projMcps || typeof projMcps !== "object") continue;
|
|
2071
|
+
pushMcpMap({
|
|
2072
|
+
out,
|
|
2073
|
+
source: "claude-code",
|
|
2074
|
+
scope: `project:${projPath}`,
|
|
2075
|
+
map: projMcps
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
async function scanCursor(home, out, errors) {
|
|
2081
|
+
const path = resolve(home, ".cursor", "mcp.json");
|
|
2082
|
+
let raw;
|
|
2083
|
+
try {
|
|
2084
|
+
raw = await promises.readFile(path, "utf8");
|
|
2085
|
+
} catch (err) {
|
|
2086
|
+
if (err.code === "ENOENT") return;
|
|
2087
|
+
errors.push(`cursor: read ${path} \u2014 ${err.message}`);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
let parsed;
|
|
2091
|
+
try {
|
|
2092
|
+
parsed = JSON.parse(raw);
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
errors.push(`cursor: JSON parse ${path} \u2014 ${err.message}`);
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
2098
|
+
const root = parsed;
|
|
2099
|
+
const map = root.mcpServers;
|
|
2100
|
+
if (map && typeof map === "object") {
|
|
2101
|
+
pushMcpMap({
|
|
2102
|
+
out,
|
|
2103
|
+
source: "cursor",
|
|
2104
|
+
scope: "global",
|
|
2105
|
+
map
|
|
2106
|
+
});
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
async function scanGoose(home, out, errors) {
|
|
2110
|
+
const path = resolve(home, ".config", "goose", "config.yaml");
|
|
2111
|
+
try {
|
|
2112
|
+
await promises.access(path);
|
|
2113
|
+
} catch {
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
out.push({
|
|
2117
|
+
id: "goose:global:_detected",
|
|
2118
|
+
source: "goose",
|
|
2119
|
+
scope: "global",
|
|
2120
|
+
name: "(goose config detected)",
|
|
2121
|
+
type: "unknown",
|
|
2122
|
+
parseNote: "Goose config found but YAML parsing is not yet implemented. Open ~/.config/goose/config.yaml to inspect MCP entries."
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
async function scanRegisteredWorkspaces(out, errors) {
|
|
2126
|
+
let workspaces = [];
|
|
2127
|
+
try {
|
|
2128
|
+
const cfg = await loadWorkspacesConfig();
|
|
2129
|
+
workspaces = cfg.workspaces;
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
errors.push(`workspaces: load failed \u2014 ${err.message}`);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
await Promise.all(
|
|
2135
|
+
workspaces.map(async (ws) => {
|
|
2136
|
+
const candidates = [
|
|
2137
|
+
resolve(ws.path, ".mcp.json"),
|
|
2138
|
+
resolve(ws.path, ".cursor", "mcp.json"),
|
|
2139
|
+
resolve(ws.path, ".vscode", "mcp.json")
|
|
2140
|
+
];
|
|
2141
|
+
for (const path of candidates) {
|
|
2142
|
+
let raw;
|
|
2143
|
+
try {
|
|
2144
|
+
raw = await promises.readFile(path, "utf8");
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
if (err.code === "ENOENT") continue;
|
|
2147
|
+
errors.push(
|
|
2148
|
+
`workspace ${ws.slug}: read ${path} \u2014 ${err.message}`
|
|
2149
|
+
);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
let parsed;
|
|
2153
|
+
try {
|
|
2154
|
+
parsed = JSON.parse(raw);
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
errors.push(
|
|
2157
|
+
`workspace ${ws.slug}: JSON parse ${path} \u2014 ${err.message}`
|
|
2158
|
+
);
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
if (!parsed || typeof parsed !== "object") continue;
|
|
2162
|
+
const map = parsed.mcpServers;
|
|
2163
|
+
if (!map || typeof map !== "object") continue;
|
|
2164
|
+
pushMcpMap({
|
|
2165
|
+
out,
|
|
2166
|
+
source: "workspace",
|
|
2167
|
+
scope: `workspace:${ws.slug}`,
|
|
2168
|
+
map
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
})
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
function pushMcpMap(args) {
|
|
2175
|
+
for (const [name, raw] of Object.entries(args.map)) {
|
|
2176
|
+
if (!raw || typeof raw !== "object") continue;
|
|
2177
|
+
const entry = raw;
|
|
2178
|
+
const id = `${args.source}:${args.scope}:${name}`;
|
|
2179
|
+
const declared = typeof entry.type === "string" ? entry.type : null;
|
|
2180
|
+
const url = typeof entry.url === "string" ? entry.url : void 0;
|
|
2181
|
+
const command = typeof entry.command === "string" ? entry.command : void 0;
|
|
2182
|
+
let type;
|
|
2183
|
+
if (declared === "http" || declared === "sse" || declared === "stdio") {
|
|
2184
|
+
type = declared;
|
|
2185
|
+
} else if (url) {
|
|
2186
|
+
type = "http";
|
|
2187
|
+
} else if (command) {
|
|
2188
|
+
type = "stdio";
|
|
2189
|
+
} else {
|
|
2190
|
+
type = "unknown";
|
|
2191
|
+
}
|
|
2192
|
+
const m = {
|
|
2193
|
+
id,
|
|
2194
|
+
source: args.source,
|
|
2195
|
+
scope: args.scope,
|
|
2196
|
+
name,
|
|
2197
|
+
type
|
|
2198
|
+
};
|
|
2199
|
+
if (command !== void 0) m.command = command;
|
|
2200
|
+
if (Array.isArray(entry.args)) {
|
|
2201
|
+
m.args = entry.args.filter((a) => typeof a === "string");
|
|
2202
|
+
}
|
|
2203
|
+
if (entry.env && typeof entry.env === "object") {
|
|
2204
|
+
m.env = stringifyValues(entry.env);
|
|
2205
|
+
}
|
|
2206
|
+
if (url !== void 0) m.url = url;
|
|
2207
|
+
if (entry.headers && typeof entry.headers === "object") {
|
|
2208
|
+
m.headers = stringifyValues(entry.headers);
|
|
2209
|
+
}
|
|
2210
|
+
if (type === "unknown") {
|
|
2211
|
+
m.parseNote = "Entry has neither `command` nor `url` \u2014 couldn't classify transport.";
|
|
2212
|
+
}
|
|
2213
|
+
args.out.push(m);
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
function stringifyValues(raw) {
|
|
2217
|
+
const out = {};
|
|
2218
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
2219
|
+
if (typeof v === "string") out[k] = v;
|
|
2220
|
+
else if (v != null) out[k] = String(v);
|
|
2221
|
+
}
|
|
2222
|
+
return out;
|
|
2223
|
+
}
|
|
2224
|
+
var IMPORTED_MCPS_PATH = () => resolve(homedir(), ".agentproto", "imported-mcps.json");
|
|
2225
|
+
var IMPORTED_MCPS_VERSION = 1;
|
|
2226
|
+
var EMPTY = {
|
|
2227
|
+
version: IMPORTED_MCPS_VERSION,
|
|
2228
|
+
imports: []
|
|
2229
|
+
};
|
|
2230
|
+
async function loadImportedMcps(path = IMPORTED_MCPS_PATH()) {
|
|
2231
|
+
let raw;
|
|
2232
|
+
try {
|
|
2233
|
+
raw = await promises.readFile(path, "utf8");
|
|
2234
|
+
} catch (err) {
|
|
2235
|
+
if (err.code === "ENOENT") return EMPTY;
|
|
2236
|
+
throw err;
|
|
2237
|
+
}
|
|
2238
|
+
let parsed;
|
|
2239
|
+
try {
|
|
2240
|
+
parsed = JSON.parse(raw);
|
|
2241
|
+
} catch (err) {
|
|
2242
|
+
throw new Error(
|
|
2243
|
+
`agentproto: ${path} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete or fix manually.`
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
return normalise(parsed);
|
|
2247
|
+
}
|
|
2248
|
+
async function saveImportedMcps(config, path = IMPORTED_MCPS_PATH()) {
|
|
2249
|
+
await promises.mkdir(dirname(path), { recursive: true });
|
|
2250
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
2251
|
+
await promises.writeFile(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
2252
|
+
await promises.rename(tmp, path);
|
|
2253
|
+
}
|
|
2254
|
+
function addImport(config, input2) {
|
|
2255
|
+
const alias = (input2.alias ?? input2.snapshot.name).trim();
|
|
2256
|
+
if (!alias) {
|
|
2257
|
+
throw new Error("addImport: alias resolves to empty string");
|
|
2258
|
+
}
|
|
2259
|
+
const addedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2260
|
+
const next = {
|
|
2261
|
+
id: input2.snapshot.id,
|
|
2262
|
+
alias,
|
|
2263
|
+
addedAt,
|
|
2264
|
+
snapshot: input2.snapshot
|
|
2265
|
+
};
|
|
2266
|
+
const others = config.imports.filter((e) => e.id !== input2.snapshot.id);
|
|
2267
|
+
return { ...config, imports: [...others, next] };
|
|
2268
|
+
}
|
|
2269
|
+
function removeImport(config, id) {
|
|
2270
|
+
return { ...config, imports: config.imports.filter((e) => e.id !== id) };
|
|
2271
|
+
}
|
|
2272
|
+
function normalise(parsed) {
|
|
2273
|
+
if (!parsed || typeof parsed !== "object") return EMPTY;
|
|
2274
|
+
const obj = parsed;
|
|
2275
|
+
const imports = [];
|
|
2276
|
+
if (Array.isArray(obj.imports)) {
|
|
2277
|
+
for (const entry of obj.imports) {
|
|
2278
|
+
if (!entry || typeof entry !== "object") continue;
|
|
2279
|
+
const e = entry;
|
|
2280
|
+
const id = typeof e.id === "string" ? e.id : "";
|
|
2281
|
+
const alias = typeof e.alias === "string" ? e.alias : id;
|
|
2282
|
+
const addedAt = typeof e.addedAt === "string" ? e.addedAt : (/* @__PURE__ */ new Date()).toISOString();
|
|
2283
|
+
const snapshot = e.snapshot;
|
|
2284
|
+
if (!id || !snapshot) continue;
|
|
2285
|
+
imports.push({ id, alias, addedAt, snapshot });
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
return { version: IMPORTED_MCPS_VERSION, imports };
|
|
2289
|
+
}
|
|
2290
|
+
function registerSessionTools(server, opts) {
|
|
2291
|
+
const { registry, resolveAgentAdapter, listAgentAdapters, mcpProxy } = opts;
|
|
2292
|
+
server.tool(
|
|
2293
|
+
"start_agent_session",
|
|
2294
|
+
"Spawn a long-running agent CLI (claude-code, hermes, \u2026) on the host. The session stays alive across multiple turns \u2014 call `prompt_agent_session` to continue the conversation. Returns the session id + initial descriptor. When `workspaceSlug` is set, resolves the cwd via `~/.agentproto/workspaces.json`; otherwise pass `cwd` explicitly or fall back to the active workspace.",
|
|
2295
|
+
{
|
|
2296
|
+
adapter: z.string().min(1).describe(
|
|
2297
|
+
"Adapter slug \u2014 one of the installed `@agentproto/adapter-*` packages (e.g. 'claude-code', 'hermes', 'aider')."
|
|
2298
|
+
),
|
|
2299
|
+
workspaceSlug: z.string().optional().describe(
|
|
2300
|
+
"Workspace slug from `agentproto workspace list`. The daemon resolves it to an absolute path. Omit to use the `cwd` field or the active workspace."
|
|
2301
|
+
),
|
|
2302
|
+
cwd: z.string().optional().describe(
|
|
2303
|
+
"Absolute path to spawn the agent in. Wins over `workspaceSlug` when both are set."
|
|
2304
|
+
),
|
|
2305
|
+
prompt: z.string().optional().describe(
|
|
2306
|
+
"Optional initial prompt. The session is spawned and the prompt dispatched in one shot \u2014 equivalent to `start` then `prompt` back-to-back. Skip to spawn idle."
|
|
2307
|
+
),
|
|
2308
|
+
label: z.string().optional().describe(
|
|
2309
|
+
"Free-text label that surfaces in `list_agent_sessions` and the UI \u2014 useful for tagging sessions with a conversation id or operator name."
|
|
2310
|
+
)
|
|
2311
|
+
},
|
|
2312
|
+
async (input2) => {
|
|
2313
|
+
if (!resolveAgentAdapter) {
|
|
2314
|
+
return {
|
|
2315
|
+
content: [
|
|
2316
|
+
{
|
|
2317
|
+
type: "text",
|
|
2318
|
+
text: "start_agent_session is not enabled \u2014 the daemon was started without an adapter resolver. Re-run the daemon with the `@agentproto/cli` shim wired (see playground/scripts/gateway.ts)."
|
|
2319
|
+
}
|
|
2320
|
+
],
|
|
2321
|
+
isError: true
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
let cwd = input2.cwd;
|
|
2325
|
+
let resolvedSlug = input2.workspaceSlug ?? "default";
|
|
2326
|
+
if (!cwd) {
|
|
2327
|
+
try {
|
|
2328
|
+
const config = await loadWorkspacesConfig();
|
|
2329
|
+
const ws = input2.workspaceSlug ? findWorkspace(config, input2.workspaceSlug) : getActiveWorkspace(config);
|
|
2330
|
+
if (ws) {
|
|
2331
|
+
cwd = ws.path;
|
|
2332
|
+
resolvedSlug = ws.slug;
|
|
2333
|
+
}
|
|
2334
|
+
} catch {
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
if (!cwd) {
|
|
2338
|
+
return {
|
|
2339
|
+
content: [
|
|
2340
|
+
{
|
|
2341
|
+
type: "text",
|
|
2342
|
+
text: "start_agent_session: no cwd resolvable. Pass `cwd` explicitly, or pass `workspaceSlug` matching `agentproto workspace list`, or set an active workspace via `agentproto workspace use <slug>`."
|
|
2343
|
+
}
|
|
2344
|
+
],
|
|
2345
|
+
isError: true
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
const resolved = await resolveAgentAdapter(input2.adapter);
|
|
2349
|
+
if (!resolved) {
|
|
2350
|
+
return {
|
|
2351
|
+
content: [
|
|
2352
|
+
{
|
|
2353
|
+
type: "text",
|
|
2354
|
+
text: `start_agent_session: adapter "${input2.adapter}" not found. Try \`agentproto install <slug>\` first.`
|
|
2355
|
+
}
|
|
2356
|
+
],
|
|
2357
|
+
isError: true
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
try {
|
|
2361
|
+
const agentSession = await resolved.startSession({ cwd });
|
|
2362
|
+
const desc = registry.spawnAgent({
|
|
2363
|
+
workspaceSlug: resolvedSlug,
|
|
2364
|
+
cwd,
|
|
2365
|
+
agentSession,
|
|
2366
|
+
adapterSlug: input2.adapter,
|
|
2367
|
+
...input2.prompt ? { initialPrompt: input2.prompt } : {},
|
|
2368
|
+
...input2.label ? { label: input2.label } : {},
|
|
2369
|
+
...resolved.commandPreview ? { commandPreview: resolved.commandPreview } : {}
|
|
2370
|
+
});
|
|
2371
|
+
return {
|
|
2372
|
+
content: [{ type: "text", text: JSON.stringify(desc, null, 2) }]
|
|
2373
|
+
};
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
return {
|
|
2376
|
+
content: [
|
|
2377
|
+
{
|
|
2378
|
+
type: "text",
|
|
2379
|
+
text: `start_agent_session: spawn failed \u2014 ${err instanceof Error ? err.message : String(err)}`
|
|
2380
|
+
}
|
|
2381
|
+
],
|
|
2382
|
+
isError: true
|
|
2383
|
+
};
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
);
|
|
2387
|
+
server.tool(
|
|
2388
|
+
"prompt_agent_session",
|
|
2389
|
+
"Send a follow-up prompt to a live agent session \u2014 multi-turn continuity without re-spawning. The session id comes from `start_agent_session` (or `list_agent_sessions`). Returns immediately; tail output via `get_agent_session_output` or the SSE /sessions/:id/stream endpoint.",
|
|
2390
|
+
{
|
|
2391
|
+
sessionId: z.string().describe("Session id returned by start_agent_session."),
|
|
2392
|
+
prompt: z.string().min(1).describe("The next user turn (plain text).")
|
|
2393
|
+
},
|
|
2394
|
+
async (input2) => {
|
|
2395
|
+
try {
|
|
2396
|
+
void registry.sendPrompt(input2.sessionId, input2.prompt).catch(() => {
|
|
2397
|
+
});
|
|
2398
|
+
return {
|
|
2399
|
+
content: [
|
|
2400
|
+
{
|
|
2401
|
+
type: "text",
|
|
2402
|
+
text: JSON.stringify(
|
|
2403
|
+
{ ok: true, sessionId: input2.sessionId, queued: true },
|
|
2404
|
+
null,
|
|
2405
|
+
2
|
|
2406
|
+
)
|
|
2407
|
+
}
|
|
2408
|
+
]
|
|
2409
|
+
};
|
|
2410
|
+
} catch (err) {
|
|
2411
|
+
return {
|
|
2412
|
+
content: [
|
|
2413
|
+
{
|
|
2414
|
+
type: "text",
|
|
2415
|
+
text: `prompt_agent_session: ${err instanceof Error ? err.message : String(err)}`
|
|
2416
|
+
}
|
|
2417
|
+
],
|
|
2418
|
+
isError: true
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
);
|
|
2423
|
+
server.tool(
|
|
2424
|
+
"list_agent_sessions",
|
|
2425
|
+
"List all sessions tracked by the daemon \u2014 running, exited, killed. Use to find a session id when the operator's previous start call returned long ago, or to tell the user what's currently active on their machine.",
|
|
2426
|
+
{
|
|
2427
|
+
onlyAlive: z.boolean().optional().describe("Filter to status running/starting/mounting only. Default false.")
|
|
2428
|
+
},
|
|
2429
|
+
async (input2) => {
|
|
2430
|
+
const all = registry.list();
|
|
2431
|
+
const filtered = input2.onlyAlive ? all.filter(
|
|
2432
|
+
(s) => s.status === "running" || s.status === "starting" || s.status === "mounting"
|
|
2433
|
+
) : all;
|
|
2434
|
+
return {
|
|
2435
|
+
content: [
|
|
2436
|
+
{
|
|
2437
|
+
type: "text",
|
|
2438
|
+
text: JSON.stringify({ sessions: filtered }, null, 2)
|
|
2439
|
+
}
|
|
2440
|
+
]
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
);
|
|
2444
|
+
server.tool(
|
|
2445
|
+
"get_agent_session_output",
|
|
2446
|
+
"Tail the recent output of a session. Returns the last N lines of the ring buffer (stdout + stderr inter-leaved, newest last). Use this to read an agent's reply after `prompt_agent_session`.",
|
|
2447
|
+
{
|
|
2448
|
+
sessionId: z.string().describe("Session id."),
|
|
2449
|
+
lastN: z.number().int().min(1).max(500).optional().describe("Max lines to return. Default 80, max 500.")
|
|
2450
|
+
},
|
|
2451
|
+
async (input2) => {
|
|
2452
|
+
const desc = registry.get(input2.sessionId);
|
|
2453
|
+
if (!desc) {
|
|
2454
|
+
return {
|
|
2455
|
+
content: [
|
|
2456
|
+
{ type: "text", text: `get_agent_session_output: no session "${input2.sessionId}"` }
|
|
2457
|
+
],
|
|
2458
|
+
isError: true
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
const limit = input2.lastN ?? 80;
|
|
2462
|
+
const lines = [];
|
|
2463
|
+
const unsub = registry.attach(input2.sessionId, (line, _stream) => {
|
|
2464
|
+
lines.push(line);
|
|
2465
|
+
});
|
|
2466
|
+
if (unsub) unsub();
|
|
2467
|
+
const tail = lines.slice(-limit);
|
|
2468
|
+
return {
|
|
2469
|
+
content: [
|
|
2470
|
+
{
|
|
2471
|
+
type: "text",
|
|
2472
|
+
text: JSON.stringify(
|
|
2473
|
+
{
|
|
2474
|
+
sessionId: input2.sessionId,
|
|
2475
|
+
status: desc.status,
|
|
2476
|
+
lastOutputAt: desc.lastOutputAt,
|
|
2477
|
+
lines: tail
|
|
2478
|
+
},
|
|
2479
|
+
null,
|
|
2480
|
+
2
|
|
2481
|
+
)
|
|
2482
|
+
}
|
|
2483
|
+
]
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
);
|
|
2487
|
+
server.tool(
|
|
2488
|
+
"list_adapters",
|
|
2489
|
+
"Enumerate every agent CLI adapter installed on the host (claude-code, hermes, aider, \u2026). Returns slug + display name + version + protocol so callers can let users pick from the installed set instead of guessing. Use before `start_agent_session` when the model doesn't already know what's available.",
|
|
2490
|
+
{},
|
|
2491
|
+
async () => {
|
|
2492
|
+
if (!listAgentAdapters) {
|
|
2493
|
+
return {
|
|
2494
|
+
content: [
|
|
2495
|
+
{
|
|
2496
|
+
type: "text",
|
|
2497
|
+
text: "list_adapters is not enabled \u2014 the daemon was started without an adapter lister. Wire `@agentproto/cli`'s `listInstalledAdapters` via `createGateway({ listAgentAdapters })`."
|
|
2498
|
+
}
|
|
2499
|
+
],
|
|
2500
|
+
isError: true
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
try {
|
|
2504
|
+
const adapters = await listAgentAdapters();
|
|
2505
|
+
return {
|
|
2506
|
+
content: [{ type: "text", text: JSON.stringify({ adapters }, null, 2) }]
|
|
2507
|
+
};
|
|
2508
|
+
} catch (err) {
|
|
2509
|
+
return {
|
|
2510
|
+
content: [
|
|
2511
|
+
{
|
|
2512
|
+
type: "text",
|
|
2513
|
+
text: `list_adapters failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2514
|
+
}
|
|
2515
|
+
],
|
|
2516
|
+
isError: true
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
);
|
|
2521
|
+
server.tool(
|
|
2522
|
+
"list_discovered_mcps",
|
|
2523
|
+
"Discover MCP servers already configured in the user's other agent tooling (claude-code, cursor, goose). Returns the union with source attribution so the operator can suggest 'I see you have a chrome-devtools MCP set up in claude \u2014 want me to use it?' instead of asking the user to re-configure. Read-only \u2014 does not modify any host's config.",
|
|
2524
|
+
{},
|
|
2525
|
+
async () => {
|
|
2526
|
+
try {
|
|
2527
|
+
const mcps = await discoverMcps();
|
|
2528
|
+
return {
|
|
2529
|
+
content: [{ type: "text", text: JSON.stringify({ mcps }, null, 2) }]
|
|
2530
|
+
};
|
|
2531
|
+
} catch (err) {
|
|
2532
|
+
return {
|
|
2533
|
+
content: [
|
|
2534
|
+
{
|
|
2535
|
+
type: "text",
|
|
2536
|
+
text: `list_discovered_mcps failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2537
|
+
}
|
|
2538
|
+
],
|
|
2539
|
+
isError: true
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
);
|
|
2544
|
+
server.tool(
|
|
2545
|
+
"list_imported_mcps",
|
|
2546
|
+
"Return the user's curated set of MCP servers \u2014 the ones they've imported from claude / cursor / workspace configs into the daemon. Use to know which MCPs the operator may freely call vs. ones still showing up in `list_discovered_mcps` waiting on the user's blessing.",
|
|
2547
|
+
{},
|
|
2548
|
+
async () => {
|
|
2549
|
+
try {
|
|
2550
|
+
const config = await loadImportedMcps();
|
|
2551
|
+
return {
|
|
2552
|
+
content: [
|
|
2553
|
+
{ type: "text", text: JSON.stringify(config, null, 2) }
|
|
2554
|
+
]
|
|
2555
|
+
};
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
return {
|
|
2558
|
+
content: [
|
|
2559
|
+
{
|
|
2560
|
+
type: "text",
|
|
2561
|
+
text: `list_imported_mcps failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2562
|
+
}
|
|
2563
|
+
],
|
|
2564
|
+
isError: true
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
);
|
|
2569
|
+
server.tool(
|
|
2570
|
+
"import_mcp",
|
|
2571
|
+
"Import a discovered MCP into the daemon's curated set. The agent calls `list_discovered_mcps` first, asks the user, then commits the choice via this tool. The snapshot is captured at import time so the entry stays usable if the source config (claude/cursor) is later removed.",
|
|
2572
|
+
{
|
|
2573
|
+
sourceMcpId: z.string().min(1).describe(
|
|
2574
|
+
"The discovered MCP id from `list_discovered_mcps` (e.g. 'claude-code:project:/path:chrome-devtools')."
|
|
2575
|
+
),
|
|
2576
|
+
alias: z.string().optional().describe(
|
|
2577
|
+
"Optional friendly name to display. Defaults to the source MCP's name."
|
|
2578
|
+
)
|
|
2579
|
+
},
|
|
2580
|
+
async (input2) => {
|
|
2581
|
+
try {
|
|
2582
|
+
const discovered = await discoverMcps();
|
|
2583
|
+
const snapshot = discovered.find((d) => d.id === input2.sourceMcpId);
|
|
2584
|
+
if (!snapshot) {
|
|
2585
|
+
return {
|
|
2586
|
+
content: [
|
|
2587
|
+
{
|
|
2588
|
+
type: "text",
|
|
2589
|
+
text: `import_mcp: discovered MCP "${input2.sourceMcpId}" not found. Re-run list_discovered_mcps to get current ids.`
|
|
2590
|
+
}
|
|
2591
|
+
],
|
|
2592
|
+
isError: true
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
const cfg = await loadImportedMcps();
|
|
2596
|
+
const next = addImport(cfg, {
|
|
2597
|
+
snapshot,
|
|
2598
|
+
...input2.alias ? { alias: input2.alias } : {}
|
|
2599
|
+
});
|
|
2600
|
+
await saveImportedMcps(next);
|
|
2601
|
+
const entry = next.imports.find((e) => e.id === snapshot.id);
|
|
2602
|
+
return {
|
|
2603
|
+
content: [{ type: "text", text: JSON.stringify(entry, null, 2) }]
|
|
2604
|
+
};
|
|
2605
|
+
} catch (err) {
|
|
2606
|
+
return {
|
|
2607
|
+
content: [
|
|
2608
|
+
{
|
|
2609
|
+
type: "text",
|
|
2610
|
+
text: `import_mcp failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2611
|
+
}
|
|
2612
|
+
],
|
|
2613
|
+
isError: true
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
);
|
|
2618
|
+
server.tool(
|
|
2619
|
+
"remove_imported_mcp",
|
|
2620
|
+
"Remove a previously-imported MCP from the daemon's curated set. Use when the user no longer wants the operator referencing it.",
|
|
2621
|
+
{
|
|
2622
|
+
id: z.string().min(1).describe(
|
|
2623
|
+
"The imported MCP id (matches the discovered MCP id at import time)."
|
|
2624
|
+
)
|
|
2625
|
+
},
|
|
2626
|
+
async (input2) => {
|
|
2627
|
+
try {
|
|
2628
|
+
const cfg = await loadImportedMcps();
|
|
2629
|
+
if (!cfg.imports.some((e) => e.id === input2.id)) {
|
|
2630
|
+
return {
|
|
2631
|
+
content: [
|
|
2632
|
+
{
|
|
2633
|
+
type: "text",
|
|
2634
|
+
text: `remove_imported_mcp: id "${input2.id}" not in imports. Use list_imported_mcps to see current entries.`
|
|
2635
|
+
}
|
|
2636
|
+
],
|
|
2637
|
+
isError: true
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
await saveImportedMcps(removeImport(cfg, input2.id));
|
|
2641
|
+
return {
|
|
2642
|
+
content: [
|
|
2643
|
+
{ type: "text", text: JSON.stringify({ ok: true, id: input2.id }, null, 2) }
|
|
2644
|
+
]
|
|
2645
|
+
};
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
return {
|
|
2648
|
+
content: [
|
|
2649
|
+
{
|
|
2650
|
+
type: "text",
|
|
2651
|
+
text: `remove_imported_mcp failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2652
|
+
}
|
|
2653
|
+
],
|
|
2654
|
+
isError: true
|
|
2655
|
+
};
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
);
|
|
2659
|
+
server.tool(
|
|
2660
|
+
"mcp_imported_status",
|
|
2661
|
+
"Snapshot every imported MCP server with its connection status, transport type, and tool count. Use this first when an operator wonders 'what MCPs do I actually have access to right now?' \u2014 the answer covers both 'imported but not yet connected' and 'connected with N tools'. Errors during connect surface in `lastError`.",
|
|
2662
|
+
{},
|
|
2663
|
+
async () => {
|
|
2664
|
+
if (!mcpProxy) {
|
|
2665
|
+
return {
|
|
2666
|
+
content: [
|
|
2667
|
+
{
|
|
2668
|
+
type: "text",
|
|
2669
|
+
text: "mcp_imported_status is not enabled \u2014 daemon was started without an MCP proxy. The host must wire `mcpProxy` in createGateway."
|
|
2670
|
+
}
|
|
2671
|
+
],
|
|
2672
|
+
isError: true
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
try {
|
|
2676
|
+
const aliases = await mcpProxy.listAliases();
|
|
2677
|
+
return {
|
|
2678
|
+
content: [
|
|
2679
|
+
{ type: "text", text: JSON.stringify({ imports: aliases }, null, 2) }
|
|
2680
|
+
]
|
|
2681
|
+
};
|
|
2682
|
+
} catch (err) {
|
|
2683
|
+
return {
|
|
2684
|
+
content: [
|
|
2685
|
+
{
|
|
2686
|
+
type: "text",
|
|
2687
|
+
text: `mcp_imported_status failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2688
|
+
}
|
|
2689
|
+
],
|
|
2690
|
+
isError: true
|
|
2691
|
+
};
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
);
|
|
2695
|
+
server.tool(
|
|
2696
|
+
"mcp_imported_list_tools",
|
|
2697
|
+
"List the tools exposed by one imported MCP server. The proxy lazily connects on first call \u2014 first-use latency includes the transport handshake (stdio: ~1-2s for npx-spawned servers; http/sse: <100ms). Returns the upstream `inputSchema` (JSON Schema) verbatim so the operator can build a valid `arguments` object for the follow-up `mcp_imported_call` invocation.",
|
|
2698
|
+
{
|
|
2699
|
+
alias: z.string().min(1).describe(
|
|
2700
|
+
"Alias from `list_imported_mcps` / `mcp_imported_status` (typically the original MCP name, e.g. 'chrome-devtools')."
|
|
2701
|
+
)
|
|
2702
|
+
},
|
|
2703
|
+
async (input2) => {
|
|
2704
|
+
if (!mcpProxy) {
|
|
2705
|
+
return {
|
|
2706
|
+
content: [
|
|
2707
|
+
{
|
|
2708
|
+
type: "text",
|
|
2709
|
+
text: "mcp_imported_list_tools is not enabled \u2014 see mcp_imported_status."
|
|
2710
|
+
}
|
|
2711
|
+
],
|
|
2712
|
+
isError: true
|
|
2713
|
+
};
|
|
2714
|
+
}
|
|
2715
|
+
const out = await mcpProxy.listTools(input2.alias);
|
|
2716
|
+
if (!out.ok) {
|
|
2717
|
+
return {
|
|
2718
|
+
content: [
|
|
2719
|
+
{
|
|
2720
|
+
type: "text",
|
|
2721
|
+
text: `mcp_imported_list_tools "${input2.alias}": ${out.error}`
|
|
2722
|
+
}
|
|
2723
|
+
],
|
|
2724
|
+
isError: true
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
return {
|
|
2728
|
+
content: [
|
|
2729
|
+
{ type: "text", text: JSON.stringify({ alias: input2.alias, tools: out.tools }, null, 2) }
|
|
2730
|
+
]
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
);
|
|
2734
|
+
server.tool(
|
|
2735
|
+
"mcp_imported_call",
|
|
2736
|
+
"Invoke a tool on an imported MCP server. The daemon proxies the call through the live client connection \u2014 the upstream server validates `arguments` against its own input schema (which you can fetch via `mcp_imported_list_tools`). The full upstream result is returned verbatim, including `isError` flags so the operator sees the original failure shape.",
|
|
2737
|
+
{
|
|
2738
|
+
alias: z.string().min(1).describe("Imported MCP alias."),
|
|
2739
|
+
toolName: z.string().min(1).describe(
|
|
2740
|
+
"Tool name as it appears in `mcp_imported_list_tools` (NOT a namespaced version \u2014 pass the upstream's own name)."
|
|
2741
|
+
),
|
|
2742
|
+
args: z.record(z.string(), z.unknown()).optional().describe(
|
|
2743
|
+
"Tool arguments as a JSON object. Schema is the upstream's \u2014 the proxy doesn't validate, only forwards. Default: empty object."
|
|
2744
|
+
)
|
|
2745
|
+
},
|
|
2746
|
+
async (input2) => {
|
|
2747
|
+
if (!mcpProxy) {
|
|
2748
|
+
return {
|
|
2749
|
+
content: [
|
|
2750
|
+
{
|
|
2751
|
+
type: "text",
|
|
2752
|
+
text: "mcp_imported_call is not enabled \u2014 see mcp_imported_status."
|
|
2753
|
+
}
|
|
2754
|
+
],
|
|
2755
|
+
isError: true
|
|
2756
|
+
};
|
|
2757
|
+
}
|
|
2758
|
+
const out = await mcpProxy.callTool(
|
|
2759
|
+
input2.alias,
|
|
2760
|
+
input2.toolName,
|
|
2761
|
+
input2.args ?? {}
|
|
2762
|
+
);
|
|
2763
|
+
if (!out.ok) {
|
|
2764
|
+
return {
|
|
2765
|
+
content: [
|
|
2766
|
+
{
|
|
2767
|
+
type: "text",
|
|
2768
|
+
text: `mcp_imported_call "${input2.alias}".${input2.toolName}: ${out.error}`
|
|
2769
|
+
}
|
|
2770
|
+
],
|
|
2771
|
+
isError: true
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
return out.result;
|
|
2775
|
+
}
|
|
2776
|
+
);
|
|
2777
|
+
server.tool(
|
|
2778
|
+
"kill_agent_session",
|
|
2779
|
+
"Stop a session \u2014 SIGTERM the underlying child + close the agent protocol session. Use to free resources after the operator is done, or when a session is wedged.",
|
|
2780
|
+
{
|
|
2781
|
+
sessionId: z.string().describe("Session id.")
|
|
2782
|
+
},
|
|
2783
|
+
async (input2) => {
|
|
2784
|
+
const ok = registry.kill(input2.sessionId);
|
|
2785
|
+
return {
|
|
2786
|
+
content: [
|
|
2787
|
+
{
|
|
2788
|
+
type: "text",
|
|
2789
|
+
text: JSON.stringify({ ok, sessionId: input2.sessionId }, null, 2)
|
|
2790
|
+
}
|
|
2791
|
+
]
|
|
2792
|
+
};
|
|
2793
|
+
}
|
|
2794
|
+
);
|
|
2795
|
+
}
|
|
2796
|
+
function startHeartbeat(opts) {
|
|
2797
|
+
const filename = opts.filename ?? "HEARTBEAT.md";
|
|
2798
|
+
const path = join(opts.workspace, filename);
|
|
2799
|
+
const now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
2800
|
+
let timer = null;
|
|
2801
|
+
let currentEveryMs = null;
|
|
2802
|
+
let firing = false;
|
|
2803
|
+
async function load() {
|
|
2804
|
+
if (!existsSync(path)) return null;
|
|
2805
|
+
let source;
|
|
2806
|
+
try {
|
|
2807
|
+
source = await readFile(path, "utf8");
|
|
2808
|
+
} catch {
|
|
2809
|
+
return null;
|
|
2810
|
+
}
|
|
2811
|
+
const parsed = matter(source);
|
|
2812
|
+
const fm = parsed.data;
|
|
2813
|
+
const agent = typeof fm.agent === "string" ? fm.agent : null;
|
|
2814
|
+
if (!agent) return null;
|
|
2815
|
+
const enabled = fm.enabled === void 0 ? true : Boolean(fm.enabled);
|
|
2816
|
+
const everyRaw = typeof fm.every === "string" ? fm.every : "60s";
|
|
2817
|
+
const everyMs = parseDuration(everyRaw);
|
|
2818
|
+
if (!everyMs) return null;
|
|
2819
|
+
const conversationTemplate = typeof fm.conversation === "string" ? fm.conversation : "heartbeat-{date}";
|
|
2820
|
+
return {
|
|
2821
|
+
agent,
|
|
2822
|
+
everyMs,
|
|
2823
|
+
enabled,
|
|
2824
|
+
conversationTemplate,
|
|
2825
|
+
body: parsed.content.trim()
|
|
2826
|
+
};
|
|
2827
|
+
}
|
|
2828
|
+
async function tick() {
|
|
2829
|
+
if (firing) return;
|
|
2830
|
+
if (opts.shouldFire && !opts.shouldFire()) return;
|
|
2831
|
+
firing = true;
|
|
2832
|
+
let agentId;
|
|
2833
|
+
try {
|
|
2834
|
+
const hb = await load();
|
|
2835
|
+
if (!hb || !hb.enabled) return;
|
|
2836
|
+
agentId = hb.agent;
|
|
2837
|
+
const agent = await opts.buildAgent(hb.agent);
|
|
2838
|
+
if (!agent) {
|
|
2839
|
+
opts.events.emit({
|
|
2840
|
+
type: "heartbeat-error",
|
|
2841
|
+
at: now().toISOString(),
|
|
2842
|
+
agent: hb.agent,
|
|
2843
|
+
error: `buildAgent('${hb.agent}') returned null`
|
|
2844
|
+
});
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
if (!hb.body) {
|
|
2848
|
+
opts.events.emit({
|
|
2849
|
+
type: "heartbeat-error",
|
|
2850
|
+
at: now().toISOString(),
|
|
2851
|
+
agent: hb.agent,
|
|
2852
|
+
error: "HEARTBEAT.md body is empty"
|
|
2853
|
+
});
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
const conversationId = expandTemplate(hb.conversationTemplate, now());
|
|
2857
|
+
await opts.conversations.open(conversationId, { agent: hb.agent });
|
|
2858
|
+
const startedAt = now();
|
|
2859
|
+
await opts.conversations.appendTurn(
|
|
2860
|
+
conversationId,
|
|
2861
|
+
"user",
|
|
2862
|
+
hb.body,
|
|
2863
|
+
{ at: startedAt.toISOString(), attribution: "heartbeat" }
|
|
2864
|
+
);
|
|
2865
|
+
opts.events.emit({
|
|
2866
|
+
type: "conv-turn-appended",
|
|
2867
|
+
at: startedAt.toISOString(),
|
|
2868
|
+
conversationId,
|
|
2869
|
+
role: "user",
|
|
2870
|
+
contentPreview: preview(hb.body)
|
|
2871
|
+
});
|
|
2872
|
+
const t0 = Date.now();
|
|
2873
|
+
const reply = await agent.generate(hb.body);
|
|
2874
|
+
const durationMs = Date.now() - t0;
|
|
2875
|
+
const finishedAt = now();
|
|
2876
|
+
await opts.conversations.appendTurn(
|
|
2877
|
+
conversationId,
|
|
2878
|
+
"assistant",
|
|
2879
|
+
reply.text,
|
|
2880
|
+
{ at: finishedAt.toISOString(), attribution: hb.agent }
|
|
2881
|
+
);
|
|
2882
|
+
opts.events.emit({
|
|
2883
|
+
type: "conv-turn-appended",
|
|
2884
|
+
at: finishedAt.toISOString(),
|
|
2885
|
+
conversationId,
|
|
2886
|
+
role: "assistant",
|
|
2887
|
+
contentPreview: preview(reply.text)
|
|
2888
|
+
});
|
|
2889
|
+
opts.events.emit({
|
|
2890
|
+
type: "heartbeat-fired",
|
|
2891
|
+
at: finishedAt.toISOString(),
|
|
2892
|
+
agent: hb.agent,
|
|
2893
|
+
conversationId,
|
|
2894
|
+
prompt: hb.body,
|
|
2895
|
+
reply: reply.text,
|
|
2896
|
+
durationMs
|
|
2897
|
+
});
|
|
2898
|
+
if (currentEveryMs !== null && currentEveryMs !== hb.everyMs) {
|
|
2899
|
+
reschedule(hb.everyMs);
|
|
2900
|
+
}
|
|
2901
|
+
} catch (err) {
|
|
2902
|
+
opts.events.emit({
|
|
2903
|
+
type: "heartbeat-error",
|
|
2904
|
+
at: now().toISOString(),
|
|
2905
|
+
agent: agentId,
|
|
2906
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2907
|
+
});
|
|
2908
|
+
} finally {
|
|
2909
|
+
firing = false;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
function reschedule(everyMs) {
|
|
2913
|
+
if (timer) clearInterval(timer);
|
|
2914
|
+
currentEveryMs = everyMs;
|
|
2915
|
+
timer = setInterval(() => {
|
|
2916
|
+
void tick();
|
|
2917
|
+
}, everyMs);
|
|
2918
|
+
}
|
|
2919
|
+
return {
|
|
2920
|
+
start() {
|
|
2921
|
+
if (timer) return;
|
|
2922
|
+
void load().then((hb) => {
|
|
2923
|
+
const everyMs = hb?.everyMs ?? 6e4;
|
|
2924
|
+
reschedule(everyMs);
|
|
2925
|
+
});
|
|
2926
|
+
},
|
|
2927
|
+
stop() {
|
|
2928
|
+
if (timer) {
|
|
2929
|
+
clearInterval(timer);
|
|
2930
|
+
timer = null;
|
|
2931
|
+
currentEveryMs = null;
|
|
2932
|
+
}
|
|
2933
|
+
},
|
|
2934
|
+
async fireNow() {
|
|
2935
|
+
await tick();
|
|
2936
|
+
}
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
var DURATION_RE = /^(\d+)\s*(s|m|h|d)$/;
|
|
2940
|
+
function parseDuration(input2) {
|
|
2941
|
+
const trimmed = input2.trim();
|
|
2942
|
+
const match = trimmed.match(DURATION_RE);
|
|
2943
|
+
if (!match) return null;
|
|
2944
|
+
const n = Number(match[1]);
|
|
2945
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
2946
|
+
switch (match[2]) {
|
|
2947
|
+
case "s":
|
|
2948
|
+
return n * 1e3;
|
|
2949
|
+
case "m":
|
|
2950
|
+
return n * 6e4;
|
|
2951
|
+
case "h":
|
|
2952
|
+
return n * 36e5;
|
|
2953
|
+
case "d":
|
|
2954
|
+
return n * 864e5;
|
|
2955
|
+
default:
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
}
|
|
2959
|
+
function expandTemplate(template, when) {
|
|
2960
|
+
const date = when.toISOString().slice(0, 10);
|
|
2961
|
+
return template.replace(/\{date\}/g, date);
|
|
2962
|
+
}
|
|
2963
|
+
function preview(text3, max = 120) {
|
|
2964
|
+
const flat = text3.replace(/\s+/g, " ").trim();
|
|
2965
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}\u2026` : flat;
|
|
2966
|
+
}
|
|
2967
|
+
async function startHttpServer(opts) {
|
|
2968
|
+
const startedAt = Date.now();
|
|
2969
|
+
const authSource = opts.auth ?? { mode: "none" };
|
|
2970
|
+
const readAuth = () => typeof authSource === "function" ? authSource() : authSource;
|
|
2971
|
+
function isLoopback(req) {
|
|
2972
|
+
const addr = req.socket.remoteAddress ?? "";
|
|
2973
|
+
const isLocalAddr = addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
|
|
2974
|
+
if (!isLocalAddr) return false;
|
|
2975
|
+
if (req.headers["x-forwarded-for"]) return false;
|
|
2976
|
+
return true;
|
|
2977
|
+
}
|
|
2978
|
+
function authorize(req, res) {
|
|
2979
|
+
const auth = readAuth();
|
|
2980
|
+
if (auth.mode === "none") return true;
|
|
2981
|
+
if (isLoopback(req)) return true;
|
|
2982
|
+
const header = req.headers.authorization;
|
|
2983
|
+
const expected = `Bearer ${auth.token}`;
|
|
2984
|
+
if (header !== expected) {
|
|
2985
|
+
res.writeHead(401, { "content-type": "application/json" });
|
|
2986
|
+
res.end(JSON.stringify({ error: "unauthorized" }));
|
|
2987
|
+
return false;
|
|
2988
|
+
}
|
|
2989
|
+
return true;
|
|
2990
|
+
}
|
|
2991
|
+
async function handleMcp(req, res) {
|
|
2992
|
+
if (!authorize(req, res)) return;
|
|
2993
|
+
const server2 = await opts.mcpServerFactory();
|
|
2994
|
+
const transport = new StreamableHTTPServerTransport({
|
|
2995
|
+
sessionIdGenerator: void 0
|
|
2996
|
+
});
|
|
2997
|
+
transport.onerror = (err) => {
|
|
2998
|
+
console.error("[mcp transport.onerror]", err);
|
|
2999
|
+
};
|
|
3000
|
+
res.on("close", () => {
|
|
3001
|
+
void transport.close();
|
|
3002
|
+
void server2.close();
|
|
3003
|
+
});
|
|
3004
|
+
try {
|
|
3005
|
+
await server2.connect(transport);
|
|
3006
|
+
await transport.handleRequest(req, res);
|
|
3007
|
+
} catch (err) {
|
|
3008
|
+
console.error("[mcp] handleRequest threw:", err);
|
|
3009
|
+
if (!res.headersSent) {
|
|
3010
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3011
|
+
res.end(
|
|
3012
|
+
JSON.stringify({
|
|
3013
|
+
error: "mcp_transport_failed",
|
|
3014
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3015
|
+
})
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
function handleHealth(_req, res) {
|
|
3021
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3022
|
+
res.end(
|
|
3023
|
+
JSON.stringify({
|
|
3024
|
+
status: "ok",
|
|
3025
|
+
workspace: opts.meta.workspace,
|
|
3026
|
+
registered: opts.meta.registered,
|
|
3027
|
+
uptimeMs: Date.now() - startedAt
|
|
3028
|
+
})
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
function handleEvents(req, res) {
|
|
3032
|
+
if (!authorize(req, res)) return;
|
|
3033
|
+
res.writeHead(200, {
|
|
3034
|
+
"content-type": "text/event-stream",
|
|
3035
|
+
"cache-control": "no-cache, no-transform",
|
|
3036
|
+
connection: "keep-alive"
|
|
3037
|
+
});
|
|
3038
|
+
res.write(`: connected
|
|
3039
|
+
|
|
3040
|
+
`);
|
|
3041
|
+
const off = opts.events.onAny((ev) => {
|
|
3042
|
+
res.write(`data: ${JSON.stringify(ev)}
|
|
3043
|
+
|
|
3044
|
+
`);
|
|
3045
|
+
});
|
|
3046
|
+
req.on("close", off);
|
|
3047
|
+
}
|
|
3048
|
+
async function handleListConversations(req, res) {
|
|
3049
|
+
if (!authorize(req, res)) return;
|
|
3050
|
+
const list = await opts.conversations.list();
|
|
3051
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3052
|
+
res.end(JSON.stringify(list));
|
|
3053
|
+
}
|
|
3054
|
+
async function handleGetConversation(req, res, id) {
|
|
3055
|
+
if (!authorize(req, res)) return;
|
|
3056
|
+
try {
|
|
3057
|
+
const data = await opts.conversations.read(id);
|
|
3058
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3059
|
+
res.end(JSON.stringify(data));
|
|
3060
|
+
} catch (err) {
|
|
3061
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
3062
|
+
res.end(
|
|
3063
|
+
JSON.stringify({
|
|
3064
|
+
error: "not_found",
|
|
3065
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3066
|
+
})
|
|
3067
|
+
);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
async function handleHeartbeatTick(req, res) {
|
|
3071
|
+
if (!authorize(req, res)) return;
|
|
3072
|
+
await opts.heartbeat.fireNow();
|
|
3073
|
+
res.writeHead(202, { "content-type": "application/json" });
|
|
3074
|
+
res.end(JSON.stringify({ status: "fired" }));
|
|
3075
|
+
}
|
|
3076
|
+
function applyCors(req, res) {
|
|
3077
|
+
const origin = req.headers.origin ?? "*";
|
|
3078
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
3079
|
+
res.setHeader("Vary", "Origin");
|
|
3080
|
+
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
3081
|
+
res.setHeader(
|
|
3082
|
+
"Access-Control-Allow-Headers",
|
|
3083
|
+
req.headers["access-control-request-headers"] ?? "authorization,content-type,mcp-session-id"
|
|
3084
|
+
);
|
|
3085
|
+
res.setHeader(
|
|
3086
|
+
"Access-Control-Allow-Methods",
|
|
3087
|
+
"GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
const server = createServer((req, res) => {
|
|
3091
|
+
void (async () => {
|
|
3092
|
+
try {
|
|
3093
|
+
const url = req.url ?? "/";
|
|
3094
|
+
const path = url.split("?")[0] ?? "/";
|
|
3095
|
+
if (req.headers["x-forwarded-for"]) {
|
|
3096
|
+
opts.events.emit({
|
|
3097
|
+
type: "remote-log",
|
|
3098
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3099
|
+
line: `[http-in] ${req.method} ${url} host=${req.headers.host ?? "?"} xff=${String(req.headers["x-forwarded-for"])}`
|
|
3100
|
+
});
|
|
3101
|
+
}
|
|
3102
|
+
applyCors(req, res);
|
|
3103
|
+
if (req.method === "OPTIONS") {
|
|
3104
|
+
res.writeHead(204);
|
|
3105
|
+
res.end();
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
if (path === "/health" && req.method === "GET") {
|
|
3109
|
+
handleHealth(req, res);
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
if (path === "/events" && req.method === "GET") {
|
|
3113
|
+
handleEvents(req, res);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
3116
|
+
if (path === "/mcp") {
|
|
3117
|
+
await handleMcp(req, res);
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
if (path === "/conversations" && req.method === "GET") {
|
|
3121
|
+
await handleListConversations(req, res);
|
|
3122
|
+
return;
|
|
3123
|
+
}
|
|
3124
|
+
if (path.startsWith("/conversations/") && req.method === "GET") {
|
|
3125
|
+
const id = path.slice("/conversations/".length);
|
|
3126
|
+
await handleGetConversation(req, res, id);
|
|
3127
|
+
return;
|
|
3128
|
+
}
|
|
3129
|
+
if (path === "/heartbeat/tick" && req.method === "POST") {
|
|
3130
|
+
await handleHeartbeatTick(req, res);
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
if (opts.sessions && path.startsWith("/sessions")) {
|
|
3134
|
+
const handled = await handleSessions(
|
|
3135
|
+
req,
|
|
3136
|
+
res,
|
|
3137
|
+
path,
|
|
3138
|
+
opts.sessions,
|
|
3139
|
+
opts.resolveAgentAdapter
|
|
3140
|
+
);
|
|
3141
|
+
if (handled) return;
|
|
3142
|
+
}
|
|
3143
|
+
if (path === "/workspaces" && req.method === "GET") {
|
|
3144
|
+
try {
|
|
3145
|
+
const config = await loadWorkspacesConfig();
|
|
3146
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3147
|
+
res.end(JSON.stringify(config));
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3150
|
+
res.end(
|
|
3151
|
+
JSON.stringify({
|
|
3152
|
+
error: "workspaces_load_failed",
|
|
3153
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3154
|
+
})
|
|
3155
|
+
);
|
|
3156
|
+
}
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (path === "/mcps/imports" && req.method === "GET") {
|
|
3160
|
+
const config = await loadImportedMcps();
|
|
3161
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3162
|
+
res.end(JSON.stringify(config));
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (path === "/mcps/imports" && req.method === "POST") {
|
|
3166
|
+
const body = await readJsonBody(req);
|
|
3167
|
+
if (!body || typeof body.sourceMcpId !== "string") {
|
|
3168
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3169
|
+
res.end(JSON.stringify({ error: "missing_sourceMcpId" }));
|
|
3170
|
+
return;
|
|
3171
|
+
}
|
|
3172
|
+
const discovered = await discoverMcps();
|
|
3173
|
+
const snapshot = discovered.find((d) => d.id === body.sourceMcpId);
|
|
3174
|
+
if (!snapshot) {
|
|
3175
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
3176
|
+
res.end(
|
|
3177
|
+
JSON.stringify({
|
|
3178
|
+
error: "discovered_mcp_not_found",
|
|
3179
|
+
sourceMcpId: body.sourceMcpId
|
|
3180
|
+
})
|
|
3181
|
+
);
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
const cfg = await loadImportedMcps();
|
|
3185
|
+
const next = addImport(cfg, {
|
|
3186
|
+
snapshot,
|
|
3187
|
+
...body.alias ? { alias: body.alias } : {}
|
|
3188
|
+
});
|
|
3189
|
+
await saveImportedMcps(next);
|
|
3190
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
3191
|
+
res.end(JSON.stringify(next.imports.find((e) => e.id === snapshot.id)));
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
const importMatch = path.match(/^\/mcps\/imports\/(.+)$/);
|
|
3195
|
+
if (importMatch && req.method === "DELETE") {
|
|
3196
|
+
const id = decodeURIComponent(importMatch[1] ?? "");
|
|
3197
|
+
const cfg = await loadImportedMcps();
|
|
3198
|
+
if (!cfg.imports.some((e) => e.id === id)) {
|
|
3199
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
3200
|
+
res.end(JSON.stringify({ error: "import_not_found", id }));
|
|
3201
|
+
return;
|
|
3202
|
+
}
|
|
3203
|
+
await saveImportedMcps(removeImport(cfg, id));
|
|
3204
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3205
|
+
res.end(JSON.stringify({ ok: true, id }));
|
|
3206
|
+
return;
|
|
3207
|
+
}
|
|
3208
|
+
if (path === "/mcps/discovered" && req.method === "GET") {
|
|
3209
|
+
try {
|
|
3210
|
+
const mcps = await discoverMcps();
|
|
3211
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3212
|
+
res.end(JSON.stringify({ mcps }));
|
|
3213
|
+
} catch (err) {
|
|
3214
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3215
|
+
res.end(
|
|
3216
|
+
JSON.stringify({
|
|
3217
|
+
error: "discovery_failed",
|
|
3218
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3219
|
+
})
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
return;
|
|
3223
|
+
}
|
|
3224
|
+
if (path === "/mcps/proxy/status" && req.method === "GET") {
|
|
3225
|
+
if (!opts.mcpProxy) {
|
|
3226
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
3227
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
try {
|
|
3231
|
+
const imports = await opts.mcpProxy.listAliases();
|
|
3232
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3233
|
+
res.end(JSON.stringify({ imports }));
|
|
3234
|
+
} catch (err) {
|
|
3235
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3236
|
+
res.end(
|
|
3237
|
+
JSON.stringify({
|
|
3238
|
+
error: "proxy_status_failed",
|
|
3239
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3240
|
+
})
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
return;
|
|
3244
|
+
}
|
|
3245
|
+
const proxyToolsMatch = path.match(/^\/mcps\/proxy\/tools\/(.+)$/);
|
|
3246
|
+
if (proxyToolsMatch && req.method === "GET") {
|
|
3247
|
+
if (!opts.mcpProxy) {
|
|
3248
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
3249
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
const alias = decodeURIComponent(proxyToolsMatch[1] ?? "");
|
|
3253
|
+
const out = await opts.mcpProxy.listTools(alias);
|
|
3254
|
+
res.writeHead(out.ok ? 200 : 502, {
|
|
3255
|
+
"content-type": "application/json"
|
|
3256
|
+
});
|
|
3257
|
+
res.end(JSON.stringify(out));
|
|
3258
|
+
return;
|
|
3259
|
+
}
|
|
3260
|
+
if (path === "/mcps/proxy/call" && req.method === "POST") {
|
|
3261
|
+
if (!opts.mcpProxy) {
|
|
3262
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
3263
|
+
res.end(JSON.stringify({ error: "mcp_proxy_not_configured" }));
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
const body = await readJsonBody(req);
|
|
3267
|
+
if (!body || typeof body.alias !== "string" || typeof body.toolName !== "string") {
|
|
3268
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
3269
|
+
res.end(
|
|
3270
|
+
JSON.stringify({
|
|
3271
|
+
error: "invalid_body",
|
|
3272
|
+
message: "expected { alias, toolName, args? }"
|
|
3273
|
+
})
|
|
3274
|
+
);
|
|
3275
|
+
return;
|
|
3276
|
+
}
|
|
3277
|
+
const result = await opts.mcpProxy.callTool(
|
|
3278
|
+
body.alias,
|
|
3279
|
+
body.toolName,
|
|
3280
|
+
body.args ?? {}
|
|
3281
|
+
);
|
|
3282
|
+
res.writeHead(result.ok ? 200 : 502, {
|
|
3283
|
+
"content-type": "application/json"
|
|
3284
|
+
});
|
|
3285
|
+
res.end(JSON.stringify(result));
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
if (path === "/adapters" && req.method === "GET") {
|
|
3289
|
+
if (!opts.listAgentAdapters) {
|
|
3290
|
+
res.writeHead(501, { "content-type": "application/json" });
|
|
3291
|
+
res.end(
|
|
3292
|
+
JSON.stringify({
|
|
3293
|
+
error: "lister_not_configured",
|
|
3294
|
+
message: "Daemon was started without `listAgentAdapters` \u2014 see @agentproto/cli's `listInstalledAdapters` for the canonical impl."
|
|
3295
|
+
})
|
|
3296
|
+
);
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
try {
|
|
3300
|
+
const adapters = await opts.listAgentAdapters();
|
|
3301
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
3302
|
+
res.end(JSON.stringify({ adapters }));
|
|
3303
|
+
} catch (err) {
|
|
3304
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3305
|
+
res.end(
|
|
3306
|
+
JSON.stringify({
|
|
3307
|
+
error: "list_failed",
|
|
3308
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3309
|
+
})
|
|
3310
|
+
);
|
|
3311
|
+
}
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
3315
|
+
res.end(JSON.stringify({ error: "not_found", path }));
|
|
3316
|
+
} catch (err) {
|
|
3317
|
+
if (!res.headersSent) {
|
|
3318
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
3319
|
+
res.end(
|
|
3320
|
+
JSON.stringify({
|
|
3321
|
+
error: "internal_error",
|
|
3322
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3323
|
+
})
|
|
3324
|
+
);
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
})();
|
|
3328
|
+
});
|
|
3329
|
+
const bind = opts.bind ?? "127.0.0.1";
|
|
3330
|
+
await new Promise((resolve7, reject) => {
|
|
3331
|
+
server.once("error", reject);
|
|
3332
|
+
server.listen(opts.port, bind, () => resolve7());
|
|
3333
|
+
});
|
|
3334
|
+
return {
|
|
3335
|
+
url: `http://${bind}:${opts.port}`,
|
|
3336
|
+
async stop() {
|
|
3337
|
+
await new Promise((resolve7) => server.close(() => resolve7()));
|
|
3338
|
+
}
|
|
3339
|
+
};
|
|
3340
|
+
}
|
|
3341
|
+
async function handleSessions(req, res, path, registry, resolveAgentAdapter) {
|
|
3342
|
+
const json = (status, body) => {
|
|
3343
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
3344
|
+
res.end(JSON.stringify(body));
|
|
3345
|
+
};
|
|
3346
|
+
if (path === "/sessions" && req.method === "GET") {
|
|
3347
|
+
json(200, { sessions: registry.list() });
|
|
3348
|
+
return true;
|
|
3349
|
+
}
|
|
3350
|
+
if (path === "/sessions/agent" && req.method === "POST") {
|
|
3351
|
+
if (!resolveAgentAdapter) {
|
|
3352
|
+
json(501, {
|
|
3353
|
+
error: "agent_resolver_not_configured",
|
|
3354
|
+
message: "POST /sessions/agent needs the host to inject `resolveAgentAdapter` (e.g. via @agentproto/cli's resolveAdapter shim)."
|
|
3355
|
+
});
|
|
3356
|
+
return true;
|
|
3357
|
+
}
|
|
3358
|
+
const body = await readJsonBody(req);
|
|
3359
|
+
if (!body || typeof body !== "object") {
|
|
3360
|
+
json(400, { error: "invalid_body" });
|
|
3361
|
+
return true;
|
|
3362
|
+
}
|
|
3363
|
+
const b = body;
|
|
3364
|
+
const adapter = typeof b.adapter === "string" ? b.adapter : "";
|
|
3365
|
+
if (!adapter) {
|
|
3366
|
+
json(400, { error: "missing_adapter" });
|
|
3367
|
+
return true;
|
|
3368
|
+
}
|
|
3369
|
+
let cwd = typeof b.cwd === "string" && b.cwd.length > 0 ? b.cwd : null;
|
|
3370
|
+
let workspaceSlug = typeof b.workspaceSlug === "string" ? b.workspaceSlug : "";
|
|
3371
|
+
if (!cwd) {
|
|
3372
|
+
try {
|
|
3373
|
+
const config = await loadWorkspacesConfig();
|
|
3374
|
+
const ws = workspaceSlug ? findWorkspace(config, workspaceSlug) : getActiveWorkspace(config);
|
|
3375
|
+
if (ws) {
|
|
3376
|
+
cwd = ws.path;
|
|
3377
|
+
workspaceSlug = ws.slug;
|
|
3378
|
+
}
|
|
3379
|
+
} catch {
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
if (!cwd) {
|
|
3383
|
+
cwd = process.cwd();
|
|
3384
|
+
console.warn(
|
|
3385
|
+
`[/sessions/agent] no cwd resolvable for adapter=${adapter} (no body.cwd, no workspaceSlug match in ~/.agentproto/workspaces.json, no active workspace) \u2014 falling back to daemon's cwd ${cwd}`
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
if (!workspaceSlug) workspaceSlug = "default";
|
|
3389
|
+
const resolved = await resolveAgentAdapter(adapter);
|
|
3390
|
+
if (!resolved) {
|
|
3391
|
+
json(404, { error: "adapter_not_found", adapter });
|
|
3392
|
+
return true;
|
|
3393
|
+
}
|
|
3394
|
+
try {
|
|
3395
|
+
const agentSession = await resolved.startSession({ cwd });
|
|
3396
|
+
const desc = registry.spawnAgent({
|
|
3397
|
+
workspaceSlug,
|
|
3398
|
+
cwd,
|
|
3399
|
+
agentSession,
|
|
3400
|
+
adapterSlug: adapter,
|
|
3401
|
+
...typeof b.prompt === "string" ? { initialPrompt: b.prompt } : {},
|
|
3402
|
+
...typeof b.label === "string" ? { label: b.label } : {},
|
|
3403
|
+
...resolved.commandPreview ? { commandPreview: resolved.commandPreview } : {}
|
|
3404
|
+
});
|
|
3405
|
+
json(201, desc);
|
|
3406
|
+
} catch (err) {
|
|
3407
|
+
json(500, {
|
|
3408
|
+
error: "agent_spawn_failed",
|
|
3409
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
return true;
|
|
3413
|
+
}
|
|
3414
|
+
const promptMatch = path.match(/^\/sessions\/([^/]+)\/prompt$/);
|
|
3415
|
+
if (promptMatch && req.method === "POST") {
|
|
3416
|
+
const id2 = promptMatch[1];
|
|
3417
|
+
if (!id2) return false;
|
|
3418
|
+
const body = await readJsonBody(req);
|
|
3419
|
+
const prompt = body?.prompt;
|
|
3420
|
+
if (typeof prompt !== "string") {
|
|
3421
|
+
json(400, { error: "missing_prompt" });
|
|
3422
|
+
return true;
|
|
3423
|
+
}
|
|
3424
|
+
const reqUrl = req.url ?? "";
|
|
3425
|
+
const queryString = reqUrl.includes("?") ? reqUrl.slice(reqUrl.indexOf("?") + 1) : "";
|
|
3426
|
+
const wait = new URLSearchParams(queryString).get("wait");
|
|
3427
|
+
const fireAndForget = wait === "false" || wait === "0";
|
|
3428
|
+
try {
|
|
3429
|
+
if (fireAndForget) {
|
|
3430
|
+
registry.enqueuePrompt(id2, prompt);
|
|
3431
|
+
json(202, { ok: true, id: id2, queued: true });
|
|
3432
|
+
} else {
|
|
3433
|
+
await registry.sendPrompt(id2, prompt);
|
|
3434
|
+
json(200, { ok: true, id: id2 });
|
|
3435
|
+
}
|
|
3436
|
+
} catch (err) {
|
|
3437
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3438
|
+
const status = msg.includes("no session") ? 404 : msg.includes("not an agent") ? 400 : msg.includes("mid-turn") ? 409 : 500;
|
|
3439
|
+
json(status, { error: "send_prompt_failed", message: msg });
|
|
3440
|
+
}
|
|
3441
|
+
return true;
|
|
3442
|
+
}
|
|
3443
|
+
if (path === "/sessions" && req.method === "POST") {
|
|
3444
|
+
const body = await readJsonBody(req);
|
|
3445
|
+
if (!body || typeof body !== "object") {
|
|
3446
|
+
json(400, { error: "invalid_body" });
|
|
3447
|
+
return true;
|
|
3448
|
+
}
|
|
3449
|
+
const b = body;
|
|
3450
|
+
const argv = Array.isArray(b.argv) ? b.argv.filter((v) => typeof v === "string") : [];
|
|
3451
|
+
if (argv.length === 0) {
|
|
3452
|
+
json(400, { error: "missing_argv" });
|
|
3453
|
+
return true;
|
|
3454
|
+
}
|
|
3455
|
+
try {
|
|
3456
|
+
const desc = registry.spawn({
|
|
3457
|
+
kind: b.kind === "terminal" || b.kind === "agent-cli" || b.kind === "command" ? b.kind : "command",
|
|
3458
|
+
workspaceSlug: typeof b.workspaceSlug === "string" ? b.workspaceSlug : "default",
|
|
3459
|
+
cwd: typeof b.cwd === "string" ? b.cwd : process.cwd(),
|
|
3460
|
+
argv,
|
|
3461
|
+
env: b.env && typeof b.env === "object" ? b.env : void 0,
|
|
3462
|
+
label: typeof b.label === "string" ? b.label : void 0
|
|
3463
|
+
});
|
|
3464
|
+
json(201, desc);
|
|
3465
|
+
} catch (err) {
|
|
3466
|
+
json(500, {
|
|
3467
|
+
error: "spawn_failed",
|
|
3468
|
+
message: err instanceof Error ? err.message : String(err)
|
|
3469
|
+
});
|
|
3470
|
+
}
|
|
3471
|
+
return true;
|
|
3472
|
+
}
|
|
3473
|
+
const idMatch = path.match(/^\/sessions\/([^/]+)(\/stream|\/kill)?$/);
|
|
3474
|
+
if (!idMatch) return false;
|
|
3475
|
+
const [, id, suffix] = idMatch;
|
|
3476
|
+
if (!id) return false;
|
|
3477
|
+
if (suffix === "/stream" && req.method === "GET") {
|
|
3478
|
+
const desc = registry.get(id);
|
|
3479
|
+
if (!desc) {
|
|
3480
|
+
json(404, { error: "session_not_found", id });
|
|
3481
|
+
return true;
|
|
3482
|
+
}
|
|
3483
|
+
res.writeHead(200, {
|
|
3484
|
+
"content-type": "text/event-stream",
|
|
3485
|
+
"cache-control": "no-cache",
|
|
3486
|
+
connection: "keep-alive"
|
|
3487
|
+
});
|
|
3488
|
+
const ping = setInterval(() => {
|
|
3489
|
+
try {
|
|
3490
|
+
res.write(`: keep-alive
|
|
3491
|
+
|
|
3492
|
+
`);
|
|
3493
|
+
} catch {
|
|
3494
|
+
clearInterval(ping);
|
|
3495
|
+
}
|
|
3496
|
+
}, 25e3);
|
|
3497
|
+
const unsub = registry.attach(id, (line, stream) => {
|
|
3498
|
+
try {
|
|
3499
|
+
res.write(
|
|
3500
|
+
`data: ${JSON.stringify({ line, stream })}
|
|
3501
|
+
|
|
3502
|
+
`
|
|
3503
|
+
);
|
|
3504
|
+
} catch {
|
|
3505
|
+
}
|
|
3506
|
+
});
|
|
3507
|
+
if (!unsub) {
|
|
3508
|
+
clearInterval(ping);
|
|
3509
|
+
res.end();
|
|
3510
|
+
return true;
|
|
3511
|
+
}
|
|
3512
|
+
req.once("close", () => {
|
|
3513
|
+
unsub();
|
|
3514
|
+
clearInterval(ping);
|
|
3515
|
+
});
|
|
3516
|
+
return true;
|
|
3517
|
+
}
|
|
3518
|
+
if (suffix === "/kill" && req.method === "POST") {
|
|
3519
|
+
const ok = registry.kill(id);
|
|
3520
|
+
json(ok ? 200 : 404, { ok, id });
|
|
3521
|
+
return true;
|
|
3522
|
+
}
|
|
3523
|
+
if (!suffix && req.method === "GET") {
|
|
3524
|
+
const desc = registry.get(id);
|
|
3525
|
+
if (!desc) {
|
|
3526
|
+
json(404, { error: "session_not_found", id });
|
|
3527
|
+
return true;
|
|
3528
|
+
}
|
|
3529
|
+
json(200, desc);
|
|
3530
|
+
return true;
|
|
3531
|
+
}
|
|
3532
|
+
if (!suffix && req.method === "DELETE") {
|
|
3533
|
+
const ok = registry.forget(id);
|
|
3534
|
+
json(ok ? 200 : 404, { ok, id });
|
|
3535
|
+
return true;
|
|
3536
|
+
}
|
|
3537
|
+
return false;
|
|
3538
|
+
}
|
|
3539
|
+
async function readJsonBody(req) {
|
|
3540
|
+
const chunks = [];
|
|
3541
|
+
for await (const chunk of req) {
|
|
3542
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
3543
|
+
}
|
|
3544
|
+
if (chunks.length === 0) return null;
|
|
3545
|
+
try {
|
|
3546
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
3547
|
+
} catch {
|
|
3548
|
+
return null;
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
var SESSIONS_FILE_PATH = () => resolve(homedir(), ".agentproto", "sessions.json");
|
|
3552
|
+
var RECENT_LINES_CAP = 500;
|
|
3553
|
+
var PERSIST_DEBOUNCE_MS = 1500;
|
|
3554
|
+
function createSessionsRegistry(opts) {
|
|
3555
|
+
const persistPath = SESSIONS_FILE_PATH();
|
|
3556
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
3557
|
+
let persistTimer = null;
|
|
3558
|
+
const schedulePersist = () => {
|
|
3559
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
3560
|
+
persistTimer = setTimeout(() => {
|
|
3561
|
+
void persistSnapshot();
|
|
3562
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
3563
|
+
};
|
|
3564
|
+
const persistSnapshot = async () => {
|
|
3565
|
+
try {
|
|
3566
|
+
const snapshot = {
|
|
3567
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3568
|
+
sessions: Array.from(sessions.values()).map((s) => s.desc)
|
|
3569
|
+
};
|
|
3570
|
+
await promises.mkdir(dirname(persistPath), { recursive: true });
|
|
3571
|
+
await promises.writeFile(persistPath, JSON.stringify(snapshot, null, 2) + "\n");
|
|
3572
|
+
} catch (err) {
|
|
3573
|
+
console.warn(
|
|
3574
|
+
`[sessions] persist failed: ${err instanceof Error ? err.message : String(err)}`
|
|
3575
|
+
);
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
const appendLine = (rt, line, stream) => {
|
|
3579
|
+
rt.recentLines.push(line);
|
|
3580
|
+
if (rt.recentLines.length > RECENT_LINES_CAP) {
|
|
3581
|
+
rt.recentLines.splice(0, rt.recentLines.length - RECENT_LINES_CAP);
|
|
3582
|
+
}
|
|
3583
|
+
rt.desc.lastOutputAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3584
|
+
rt.emitter.emit("line", { line, stream });
|
|
3585
|
+
};
|
|
3586
|
+
const projectEvent = (rt, evt) => {
|
|
3587
|
+
switch (evt.kind) {
|
|
3588
|
+
case "text-delta":
|
|
3589
|
+
if (evt.text) {
|
|
3590
|
+
const combined = rt.textBuf + evt.text;
|
|
3591
|
+
const lines = combined.split(/\r?\n/);
|
|
3592
|
+
rt.textBuf = lines.pop() ?? "";
|
|
3593
|
+
for (const line of lines) appendLine(rt, line, "stdout");
|
|
3594
|
+
}
|
|
3595
|
+
break;
|
|
3596
|
+
case "thought":
|
|
3597
|
+
if (evt.text) {
|
|
3598
|
+
const combined = rt.thoughtBuf + evt.text;
|
|
3599
|
+
const lines = combined.split(/\r?\n/);
|
|
3600
|
+
rt.thoughtBuf = lines.pop() ?? "";
|
|
3601
|
+
for (const line of lines) {
|
|
3602
|
+
if (line.trim()) {
|
|
3603
|
+
appendLine(rt, `\x1B[2m[thought] ${line}\x1B[0m`, "stdout");
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
3607
|
+
break;
|
|
3608
|
+
case "tool-call":
|
|
3609
|
+
appendLine(
|
|
3610
|
+
rt,
|
|
3611
|
+
`\x1B[36m[tool] ${evt.toolName ?? "?"}\x1B[0m`,
|
|
3612
|
+
"stdout"
|
|
3613
|
+
);
|
|
3614
|
+
break;
|
|
3615
|
+
case "tool-result":
|
|
3616
|
+
if (evt.isError)
|
|
3617
|
+
appendLine(rt, `\x1B[31m[tool-error]\x1B[0m`, "stderr");
|
|
3618
|
+
break;
|
|
3619
|
+
case "agent-prompt":
|
|
3620
|
+
appendLine(rt, `\x1B[33m[awaiting input]\x1B[0m`, "stdout");
|
|
3621
|
+
break;
|
|
3622
|
+
case "turn-end": {
|
|
3623
|
+
if (rt.thoughtBuf.trim()) {
|
|
3624
|
+
appendLine(
|
|
3625
|
+
rt,
|
|
3626
|
+
`\x1B[2m[thought] ${rt.thoughtBuf}\x1B[0m`,
|
|
3627
|
+
"stdout"
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
rt.thoughtBuf = "";
|
|
3631
|
+
if (rt.textBuf) {
|
|
3632
|
+
appendLine(rt, rt.textBuf, "stdout");
|
|
3633
|
+
rt.textBuf = "";
|
|
3634
|
+
}
|
|
3635
|
+
appendLine(
|
|
3636
|
+
rt,
|
|
3637
|
+
`\x1B[2m\u2500\u2500 turn-end (${evt.reason ?? "completed"}) \u2500\u2500\x1B[0m`,
|
|
3638
|
+
"stdout"
|
|
3639
|
+
);
|
|
3640
|
+
break;
|
|
3641
|
+
}
|
|
3642
|
+
case "error": {
|
|
3643
|
+
const code = typeof evt.error?.code === "number" ? ` (code ${evt.error.code})` : "";
|
|
3644
|
+
appendLine(
|
|
3645
|
+
rt,
|
|
3646
|
+
`\x1B[31m[error]${code} ${evt.error?.message ?? "unknown"}\x1B[0m`,
|
|
3647
|
+
"stderr"
|
|
3648
|
+
);
|
|
3649
|
+
const data = evt.error?.data;
|
|
3650
|
+
if (data && typeof data === "object" && typeof data.stderr === "string") {
|
|
3651
|
+
for (const line of data.stderr.split(/\r?\n/)) {
|
|
3652
|
+
if (line) appendLine(rt, `\x1B[2m ${line}\x1B[0m`, "stderr");
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
break;
|
|
3656
|
+
}
|
|
3657
|
+
}
|
|
3658
|
+
};
|
|
3659
|
+
const validateAgentTurn = (id, caller) => {
|
|
3660
|
+
const rt = sessions.get(id);
|
|
3661
|
+
if (!rt) throw new Error(`${caller}: no session "${id}"`);
|
|
3662
|
+
if (!rt.agentSession) {
|
|
3663
|
+
throw new Error(
|
|
3664
|
+
`${caller}: session "${id}" is not an agent session (kind=${rt.desc.kind})`
|
|
3665
|
+
);
|
|
3666
|
+
}
|
|
3667
|
+
if (rt.busy) {
|
|
3668
|
+
throw new Error(
|
|
3669
|
+
`${caller}: session "${id}" is mid-turn \u2014 wait for it to finish or cancel`
|
|
3670
|
+
);
|
|
3671
|
+
}
|
|
3672
|
+
return rt;
|
|
3673
|
+
};
|
|
3674
|
+
const runAgentTurn = async (rt, message) => {
|
|
3675
|
+
if (!rt.agentSession) {
|
|
3676
|
+
throw new Error("runAgentTurn: session has no agentSession");
|
|
3677
|
+
}
|
|
3678
|
+
rt.busy = true;
|
|
3679
|
+
try {
|
|
3680
|
+
appendLine(
|
|
3681
|
+
rt,
|
|
3682
|
+
`\x1B[2m\u2500\u2500 \u25B6 ${typeof message === "string" ? message : JSON.stringify(message)} \u2500\u2500\x1B[0m`,
|
|
3683
|
+
"stdout"
|
|
3684
|
+
);
|
|
3685
|
+
const wrapped = typeof message === "string" ? { type: "text", text: message } : message;
|
|
3686
|
+
for await (const evt of rt.agentSession.send(wrapped)) {
|
|
3687
|
+
projectEvent(rt, evt);
|
|
3688
|
+
}
|
|
3689
|
+
} catch (err) {
|
|
3690
|
+
rt.desc.status = "error";
|
|
3691
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3692
|
+
appendLine(
|
|
3693
|
+
rt,
|
|
3694
|
+
`[turn error] ${err instanceof Error ? err.message : String(err)}`,
|
|
3695
|
+
"stderr"
|
|
3696
|
+
);
|
|
3697
|
+
schedulePersist();
|
|
3698
|
+
} finally {
|
|
3699
|
+
rt.busy = false;
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
const wireOutputStreams = (rt) => {
|
|
3703
|
+
const onChunk = (stream) => (chunk) => {
|
|
3704
|
+
const text3 = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
3705
|
+
for (const line of text3.split(/\r?\n/)) {
|
|
3706
|
+
if (line.length > 0) appendLine(rt, line, stream);
|
|
3707
|
+
}
|
|
3708
|
+
};
|
|
3709
|
+
if (!rt.child) return;
|
|
3710
|
+
rt.child.stdout?.on("data", onChunk("stdout"));
|
|
3711
|
+
rt.child.stderr?.on("data", onChunk("stderr"));
|
|
3712
|
+
};
|
|
3713
|
+
return {
|
|
3714
|
+
spawn(input2) {
|
|
3715
|
+
const id = input2.id ?? `sess_${randomUUID().slice(0, 8)}`;
|
|
3716
|
+
if (sessions.has(id)) {
|
|
3717
|
+
throw new Error(`sessions.spawn: id "${id}" already in use`);
|
|
3718
|
+
}
|
|
3719
|
+
const env = { ...process.env, ...input2.env ?? {} };
|
|
3720
|
+
delete env.NODE_OPTIONS;
|
|
3721
|
+
const [bin, ...args] = input2.argv;
|
|
3722
|
+
if (!bin) throw new Error("sessions.spawn: argv must include a binary");
|
|
3723
|
+
const stdinMode = input2.keepStdin ? "pipe" : "ignore";
|
|
3724
|
+
const child = spawn(bin, args, {
|
|
3725
|
+
cwd: input2.cwd,
|
|
3726
|
+
env,
|
|
3727
|
+
stdio: [stdinMode, "pipe", "pipe"]
|
|
3728
|
+
});
|
|
3729
|
+
const desc = {
|
|
3730
|
+
id,
|
|
3731
|
+
kind: input2.kind,
|
|
3732
|
+
workspaceSlug: input2.workspaceSlug,
|
|
3733
|
+
command: [bin, ...args].map(quoteArg).join(" "),
|
|
3734
|
+
pid: child.pid ?? null,
|
|
3735
|
+
status: "starting",
|
|
3736
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3737
|
+
...input2.label ? { label: input2.label } : {}
|
|
3738
|
+
};
|
|
3739
|
+
const rt = {
|
|
3740
|
+
desc,
|
|
3741
|
+
child,
|
|
3742
|
+
recentLines: [],
|
|
3743
|
+
emitter: new EventEmitter(),
|
|
3744
|
+
busy: false,
|
|
3745
|
+
textBuf: "",
|
|
3746
|
+
thoughtBuf: ""
|
|
3747
|
+
};
|
|
3748
|
+
rt.emitter.setMaxListeners(50);
|
|
3749
|
+
sessions.set(id, rt);
|
|
3750
|
+
wireOutputStreams(rt);
|
|
3751
|
+
child.once("spawn", () => {
|
|
3752
|
+
desc.status = "running";
|
|
3753
|
+
rt.emitter.emit("status", desc.status);
|
|
3754
|
+
schedulePersist();
|
|
3755
|
+
});
|
|
3756
|
+
child.once("error", (err) => {
|
|
3757
|
+
desc.status = "error";
|
|
3758
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3759
|
+
appendLine(rt, `[spawn error] ${err.message}`, "stderr");
|
|
3760
|
+
rt.emitter.emit("status", desc.status);
|
|
3761
|
+
schedulePersist();
|
|
3762
|
+
});
|
|
3763
|
+
child.once("exit", (code, signal) => {
|
|
3764
|
+
if (signal && desc.status !== "killed") desc.status = "killed";
|
|
3765
|
+
else if (desc.status === "running" || desc.status === "starting") {
|
|
3766
|
+
desc.status = "exited";
|
|
3767
|
+
}
|
|
3768
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3769
|
+
if (typeof code === "number") desc.exitCode = code;
|
|
3770
|
+
rt.emitter.emit("status", desc.status);
|
|
3771
|
+
schedulePersist();
|
|
3772
|
+
});
|
|
3773
|
+
schedulePersist();
|
|
3774
|
+
return desc;
|
|
3775
|
+
},
|
|
3776
|
+
register(input2) {
|
|
3777
|
+
if (sessions.has(input2.id)) {
|
|
3778
|
+
const existing = sessions.get(input2.id);
|
|
3779
|
+
if (existing) return existing.desc;
|
|
3780
|
+
}
|
|
3781
|
+
const desc = {
|
|
3782
|
+
id: input2.id,
|
|
3783
|
+
kind: input2.kind ?? "command",
|
|
3784
|
+
workspaceSlug: input2.workspaceSlug,
|
|
3785
|
+
command: input2.command,
|
|
3786
|
+
pid: input2.child.pid ?? null,
|
|
3787
|
+
status: input2.child.killed ? "killed" : "running",
|
|
3788
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3789
|
+
...input2.label ? { label: input2.label } : {}
|
|
3790
|
+
};
|
|
3791
|
+
const rt = {
|
|
3792
|
+
desc,
|
|
3793
|
+
child: input2.child,
|
|
3794
|
+
recentLines: [],
|
|
3795
|
+
emitter: new EventEmitter(),
|
|
3796
|
+
busy: false,
|
|
3797
|
+
textBuf: "",
|
|
3798
|
+
thoughtBuf: ""
|
|
3799
|
+
};
|
|
3800
|
+
rt.emitter.setMaxListeners(50);
|
|
3801
|
+
sessions.set(input2.id, rt);
|
|
3802
|
+
wireOutputStreams(rt);
|
|
3803
|
+
input2.child.once("exit", (code, signal) => {
|
|
3804
|
+
if (signal && desc.status !== "killed") desc.status = "killed";
|
|
3805
|
+
else if (desc.status === "running") desc.status = "exited";
|
|
3806
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3807
|
+
if (typeof code === "number") desc.exitCode = code;
|
|
3808
|
+
rt.emitter.emit("status", desc.status);
|
|
3809
|
+
schedulePersist();
|
|
3810
|
+
});
|
|
3811
|
+
input2.child.once("error", (err) => {
|
|
3812
|
+
desc.status = "error";
|
|
3813
|
+
desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3814
|
+
appendLine(rt, `[child error] ${err.message}`, "stderr");
|
|
3815
|
+
rt.emitter.emit("status", desc.status);
|
|
3816
|
+
schedulePersist();
|
|
3817
|
+
});
|
|
3818
|
+
schedulePersist();
|
|
3819
|
+
return desc;
|
|
3820
|
+
},
|
|
3821
|
+
spawnAgent(input2) {
|
|
3822
|
+
const id = `sess_${randomUUID().slice(0, 8)}`;
|
|
3823
|
+
const desc = {
|
|
3824
|
+
id,
|
|
3825
|
+
kind: "agent-cli",
|
|
3826
|
+
workspaceSlug: input2.workspaceSlug,
|
|
3827
|
+
command: input2.commandPreview ?? `${input2.adapterSlug} (agent)`,
|
|
3828
|
+
pid: null,
|
|
3829
|
+
status: "running",
|
|
3830
|
+
// driver already started the session
|
|
3831
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3832
|
+
...input2.label ? { label: input2.label } : {}
|
|
3833
|
+
};
|
|
3834
|
+
const rt = {
|
|
3835
|
+
desc,
|
|
3836
|
+
agentSession: input2.agentSession,
|
|
3837
|
+
adapterSlug: input2.adapterSlug,
|
|
3838
|
+
recentLines: [],
|
|
3839
|
+
emitter: new EventEmitter(),
|
|
3840
|
+
busy: false,
|
|
3841
|
+
textBuf: "",
|
|
3842
|
+
thoughtBuf: ""
|
|
3843
|
+
};
|
|
3844
|
+
rt.emitter.setMaxListeners(50);
|
|
3845
|
+
sessions.set(id, rt);
|
|
3846
|
+
appendLine(
|
|
3847
|
+
rt,
|
|
3848
|
+
`\u2500\u2500 ${input2.adapterSlug} agent session ${input2.agentSession.sessionId} (cwd ${input2.cwd}) \u2500\u2500`,
|
|
3849
|
+
"stdout"
|
|
3850
|
+
);
|
|
3851
|
+
schedulePersist();
|
|
3852
|
+
if (input2.initialPrompt) {
|
|
3853
|
+
void runAgentTurn(rt, input2.initialPrompt).catch((err) => {
|
|
3854
|
+
appendLine(
|
|
3855
|
+
rt,
|
|
3856
|
+
`[turn error] ${err instanceof Error ? err.message : String(err)}`,
|
|
3857
|
+
"stderr"
|
|
3858
|
+
);
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
return desc;
|
|
3862
|
+
},
|
|
3863
|
+
async sendPrompt(id, message) {
|
|
3864
|
+
const rt = validateAgentTurn(id, "sendPrompt");
|
|
3865
|
+
await runAgentTurn(rt, message);
|
|
3866
|
+
},
|
|
3867
|
+
enqueuePrompt(id, message) {
|
|
3868
|
+
const rt = validateAgentTurn(id, "enqueuePrompt");
|
|
3869
|
+
void runAgentTurn(rt, message).catch(() => {
|
|
3870
|
+
});
|
|
3871
|
+
},
|
|
3872
|
+
list() {
|
|
3873
|
+
return Array.from(sessions.values()).map((s) => s.desc).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3874
|
+
},
|
|
3875
|
+
get(id) {
|
|
3876
|
+
return sessions.get(id)?.desc;
|
|
3877
|
+
},
|
|
3878
|
+
attach(id, onLine) {
|
|
3879
|
+
const rt = sessions.get(id);
|
|
3880
|
+
if (!rt) return null;
|
|
3881
|
+
for (const line of rt.recentLines) onLine(line, "stdout");
|
|
3882
|
+
const handler = (evt) => onLine(evt.line, evt.stream);
|
|
3883
|
+
rt.emitter.on("line", handler);
|
|
3884
|
+
return () => rt.emitter.off("line", handler);
|
|
3885
|
+
},
|
|
3886
|
+
kill(id, signal = "SIGTERM") {
|
|
3887
|
+
const rt = sessions.get(id);
|
|
3888
|
+
if (!rt) return false;
|
|
3889
|
+
if (rt.desc.status === "exited" || rt.desc.status === "killed" || rt.desc.status === "error") {
|
|
3890
|
+
return false;
|
|
3891
|
+
}
|
|
3892
|
+
rt.desc.status = "killed";
|
|
3893
|
+
rt.desc.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3894
|
+
if (rt.agentSession) {
|
|
3895
|
+
void rt.agentSession.close().catch(() => void 0);
|
|
3896
|
+
}
|
|
3897
|
+
rt.child?.kill(signal);
|
|
3898
|
+
schedulePersist();
|
|
3899
|
+
return true;
|
|
3900
|
+
},
|
|
3901
|
+
forget(id) {
|
|
3902
|
+
const rt = sessions.get(id);
|
|
3903
|
+
if (!rt) return false;
|
|
3904
|
+
rt.emitter.removeAllListeners();
|
|
3905
|
+
sessions.delete(id);
|
|
3906
|
+
schedulePersist();
|
|
3907
|
+
return true;
|
|
3908
|
+
},
|
|
3909
|
+
shutdown() {
|
|
3910
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
3911
|
+
for (const rt of sessions.values()) {
|
|
3912
|
+
rt.emitter.removeAllListeners();
|
|
3913
|
+
if (rt.desc.status === "running" || rt.desc.status === "starting") {
|
|
3914
|
+
if (rt.agentSession) {
|
|
3915
|
+
void rt.agentSession.close().catch(() => void 0);
|
|
3916
|
+
}
|
|
3917
|
+
rt.child?.kill("SIGTERM");
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
sessions.clear();
|
|
3921
|
+
}
|
|
3922
|
+
};
|
|
3923
|
+
}
|
|
3924
|
+
function quoteArg(arg) {
|
|
3925
|
+
if (arg === "") return '""';
|
|
3926
|
+
if (/^[a-zA-Z0-9._/=:@,+-]+$/.test(arg)) return arg;
|
|
3927
|
+
return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
3928
|
+
}
|
|
3929
|
+
var McpProxyRegistry = class {
|
|
3930
|
+
clients = /* @__PURE__ */ new Map();
|
|
3931
|
+
lastMtimeMs = 0;
|
|
3932
|
+
/** Sentinel so we know whether the file has *ever* been read.
|
|
3933
|
+
* Distinguishes "file missing on first call" (legit empty state)
|
|
3934
|
+
* from "we've read it before and it's gone" (treat as empty too,
|
|
3935
|
+
* but only after closing live clients). */
|
|
3936
|
+
hasReadOnce = false;
|
|
3937
|
+
/**
|
|
3938
|
+
* Reload the imports file when its mtime has advanced. Avoids
|
|
3939
|
+
* repeated parsing when nothing changed — most calls are no-ops.
|
|
3940
|
+
* Disconnects clients whose import was removed; connects the new
|
|
3941
|
+
* set lazily (the first call that touches them).
|
|
3942
|
+
*/
|
|
3943
|
+
async ensureCurrent() {
|
|
3944
|
+
let mtimeMs = 0;
|
|
3945
|
+
try {
|
|
3946
|
+
const st = await promises.stat(IMPORTED_MCPS_PATH());
|
|
3947
|
+
mtimeMs = st.mtimeMs;
|
|
3948
|
+
} catch {
|
|
3949
|
+
mtimeMs = 0;
|
|
3950
|
+
}
|
|
3951
|
+
if (this.hasReadOnce && mtimeMs === this.lastMtimeMs) return;
|
|
3952
|
+
const config = await loadImportedMcps().catch(() => ({
|
|
3953
|
+
version: 1,
|
|
3954
|
+
imports: []
|
|
3955
|
+
}));
|
|
3956
|
+
const wantedIds = new Set(config.imports.map((e) => e.id));
|
|
3957
|
+
for (const [id, c] of this.clients.entries()) {
|
|
3958
|
+
if (!wantedIds.has(id)) {
|
|
3959
|
+
await safeClose(c.client);
|
|
3960
|
+
this.clients.delete(id);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
for (const entry of config.imports) {
|
|
3964
|
+
const existing = this.clients.get(entry.id);
|
|
3965
|
+
if (existing) {
|
|
3966
|
+
if (JSON.stringify(existing.entry.snapshot) !== JSON.stringify(entry.snapshot)) {
|
|
3967
|
+
await safeClose(existing.client);
|
|
3968
|
+
this.clients.set(entry.id, freshPlaceholder(entry));
|
|
3969
|
+
} else {
|
|
3970
|
+
existing.entry = entry;
|
|
3971
|
+
}
|
|
3972
|
+
} else {
|
|
3973
|
+
this.clients.set(entry.id, freshPlaceholder(entry));
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
this.lastMtimeMs = mtimeMs;
|
|
3977
|
+
this.hasReadOnce = true;
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* Lazy connect: the first call to `connectIfNeeded(alias)`
|
|
3981
|
+
* actually opens the transport + handshakes. Subsequent calls
|
|
3982
|
+
* reuse the live client.
|
|
3983
|
+
*/
|
|
3984
|
+
async connectIfNeeded(alias) {
|
|
3985
|
+
await this.ensureCurrent();
|
|
3986
|
+
const handle = this.findByAlias(alias);
|
|
3987
|
+
if (!handle) return null;
|
|
3988
|
+
if (handle.client) return handle;
|
|
3989
|
+
try {
|
|
3990
|
+
const client = await openClient(handle.entry);
|
|
3991
|
+
handle.client = client;
|
|
3992
|
+
handle.lastError = null;
|
|
3993
|
+
const t = await client.listTools();
|
|
3994
|
+
handle.tools = t.tools.map(toDescriptor);
|
|
3995
|
+
return handle;
|
|
3996
|
+
} catch (err) {
|
|
3997
|
+
handle.client = null;
|
|
3998
|
+
handle.tools = null;
|
|
3999
|
+
handle.lastError = err instanceof Error ? err.message : String(err);
|
|
4000
|
+
return handle;
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
/** Find a client handle by alias *or* import id (defensive — agents
|
|
4004
|
+
* sometimes pass the id from `list_imported_mcps`). */
|
|
4005
|
+
findByAlias(aliasOrId) {
|
|
4006
|
+
for (const c of this.clients.values()) {
|
|
4007
|
+
if (c.entry.alias === aliasOrId) return c;
|
|
4008
|
+
if (c.entry.id === aliasOrId) return c;
|
|
4009
|
+
}
|
|
4010
|
+
return void 0;
|
|
4011
|
+
}
|
|
4012
|
+
async listAliases() {
|
|
4013
|
+
await this.ensureCurrent();
|
|
4014
|
+
return Array.from(this.clients.values()).map((c) => ({
|
|
4015
|
+
alias: c.entry.alias,
|
|
4016
|
+
importId: c.entry.id,
|
|
4017
|
+
source: c.entry.snapshot.source,
|
|
4018
|
+
type: c.entry.snapshot.type,
|
|
4019
|
+
status: c.client ? "connected" : c.lastError ? "error" : "pending",
|
|
4020
|
+
toolCount: c.tools?.length ?? 0,
|
|
4021
|
+
...c.lastError ? { lastError: c.lastError } : {}
|
|
4022
|
+
}));
|
|
4023
|
+
}
|
|
4024
|
+
async listTools(alias) {
|
|
4025
|
+
const handle = await this.connectIfNeeded(alias);
|
|
4026
|
+
if (!handle) return { ok: false, error: `unknown alias "${alias}"` };
|
|
4027
|
+
if (!handle.client || !handle.tools) {
|
|
4028
|
+
return {
|
|
4029
|
+
ok: false,
|
|
4030
|
+
error: handle.lastError ?? "client not connected"
|
|
4031
|
+
};
|
|
4032
|
+
}
|
|
4033
|
+
return { ok: true, tools: handle.tools };
|
|
4034
|
+
}
|
|
4035
|
+
async callTool(alias, toolName, args) {
|
|
4036
|
+
const handle = await this.connectIfNeeded(alias);
|
|
4037
|
+
if (!handle) return { ok: false, error: `unknown alias "${alias}"` };
|
|
4038
|
+
if (!handle.client) {
|
|
4039
|
+
return {
|
|
4040
|
+
ok: false,
|
|
4041
|
+
error: handle.lastError ?? "client not connected"
|
|
4042
|
+
};
|
|
4043
|
+
}
|
|
4044
|
+
try {
|
|
4045
|
+
const result = await handle.client.callTool({
|
|
4046
|
+
name: toolName,
|
|
4047
|
+
arguments: args && typeof args === "object" ? args : {}
|
|
4048
|
+
});
|
|
4049
|
+
return { ok: true, result };
|
|
4050
|
+
} catch (err) {
|
|
4051
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4052
|
+
if (/closed|disconnect|EPIPE|ECONNRESET/i.test(msg)) {
|
|
4053
|
+
await safeClose(handle.client);
|
|
4054
|
+
handle.client = null;
|
|
4055
|
+
handle.tools = null;
|
|
4056
|
+
handle.lastError = msg;
|
|
4057
|
+
}
|
|
4058
|
+
return { ok: false, error: msg };
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
async closeAll() {
|
|
4062
|
+
await Promise.all(
|
|
4063
|
+
Array.from(this.clients.values()).map((c) => safeClose(c.client))
|
|
4064
|
+
);
|
|
4065
|
+
this.clients.clear();
|
|
4066
|
+
}
|
|
4067
|
+
};
|
|
4068
|
+
function freshPlaceholder(entry) {
|
|
4069
|
+
return { entry, client: null, tools: null, lastError: null };
|
|
4070
|
+
}
|
|
4071
|
+
function toDescriptor(tool) {
|
|
4072
|
+
return {
|
|
4073
|
+
name: tool.name,
|
|
4074
|
+
...tool.description ? { description: tool.description } : {},
|
|
4075
|
+
...tool.inputSchema ? { inputSchema: tool.inputSchema } : {}
|
|
4076
|
+
};
|
|
4077
|
+
}
|
|
4078
|
+
function expandEnvPlaceholders(raw) {
|
|
4079
|
+
const out = {};
|
|
4080
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
4081
|
+
const expanded = v.replace(
|
|
4082
|
+
/\$\{([A-Z_][A-Z0-9_]*)(?::-([^}]*))?\}/gi,
|
|
4083
|
+
(_match, name, fallback) => {
|
|
4084
|
+
const fromEnv = process.env[name];
|
|
4085
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
4086
|
+
if (fallback !== void 0) return fallback;
|
|
4087
|
+
return "";
|
|
4088
|
+
}
|
|
4089
|
+
);
|
|
4090
|
+
if (expanded.length === 0) continue;
|
|
4091
|
+
out[k] = expanded;
|
|
4092
|
+
}
|
|
4093
|
+
return out;
|
|
4094
|
+
}
|
|
4095
|
+
async function safeClose(client) {
|
|
4096
|
+
if (!client) return;
|
|
4097
|
+
try {
|
|
4098
|
+
await client.close();
|
|
4099
|
+
} catch {
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
async function openClient(entry) {
|
|
4103
|
+
const snap = entry.snapshot;
|
|
4104
|
+
const client = new Client(
|
|
4105
|
+
{ name: "agentproto-daemon-proxy", version: "0.1.0" },
|
|
4106
|
+
{ capabilities: {} }
|
|
4107
|
+
);
|
|
4108
|
+
if (snap.type === "stdio") {
|
|
4109
|
+
if (!snap.command) {
|
|
4110
|
+
throw new Error(
|
|
4111
|
+
`import "${entry.alias}" is stdio but has no command field`
|
|
4112
|
+
);
|
|
4113
|
+
}
|
|
4114
|
+
const expandedEnv = expandEnvPlaceholders(snap.env ?? {});
|
|
4115
|
+
const transport = new StdioClientTransport({
|
|
4116
|
+
command: snap.command,
|
|
4117
|
+
args: snap.args ?? [],
|
|
4118
|
+
env: { ...process.env, ...expandedEnv }
|
|
4119
|
+
});
|
|
4120
|
+
await client.connect(transport);
|
|
4121
|
+
return client;
|
|
4122
|
+
}
|
|
4123
|
+
if (snap.type === "http") {
|
|
4124
|
+
if (!snap.url) throw new Error(`import "${entry.alias}" has no url`);
|
|
4125
|
+
const transport = new StreamableHTTPClientTransport(new URL(snap.url), {
|
|
4126
|
+
requestInit: {
|
|
4127
|
+
headers: snap.headers ?? {}
|
|
4128
|
+
}
|
|
4129
|
+
});
|
|
4130
|
+
await client.connect(transport);
|
|
4131
|
+
return client;
|
|
4132
|
+
}
|
|
4133
|
+
if (snap.type === "sse") {
|
|
4134
|
+
if (!snap.url) throw new Error(`import "${entry.alias}" has no url`);
|
|
4135
|
+
const transport = new SSEClientTransport(new URL(snap.url), {
|
|
4136
|
+
requestInit: {
|
|
4137
|
+
headers: snap.headers ?? {}
|
|
4138
|
+
}
|
|
4139
|
+
});
|
|
4140
|
+
await client.connect(transport);
|
|
4141
|
+
return client;
|
|
4142
|
+
}
|
|
4143
|
+
throw new Error(
|
|
4144
|
+
`import "${entry.alias}" has unsupported transport type "${snap.type}"`
|
|
4145
|
+
);
|
|
4146
|
+
}
|
|
4147
|
+
var execFileAsync = promisify(execFile);
|
|
4148
|
+
var URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
4149
|
+
var STARTUP_TIMEOUT_MS = 3e4;
|
|
4150
|
+
function quickTunnelProvider() {
|
|
4151
|
+
let child = null;
|
|
4152
|
+
let configPath = null;
|
|
4153
|
+
return {
|
|
4154
|
+
id: "quick",
|
|
4155
|
+
async start(opts) {
|
|
4156
|
+
await assertCloudflaredOnPath();
|
|
4157
|
+
const targetUrl = `http://${opts.target.host}:${opts.target.port}`;
|
|
4158
|
+
const id = randomBytes(4).toString("hex");
|
|
4159
|
+
const dir = join(opts.workspace, ".agentproto");
|
|
4160
|
+
await mkdir(dir, { recursive: true });
|
|
4161
|
+
configPath = join(dir, `cloudflared-${id}.yml`);
|
|
4162
|
+
const configBody = [
|
|
4163
|
+
`# generated by agentproto-runtime \u2014 sentinel only`,
|
|
4164
|
+
`# overrides ~/.cloudflared/config.yml so its ingress rules`,
|
|
4165
|
+
`# don't shadow the inline --url rule. Safe to delete when the`,
|
|
4166
|
+
`# tunnel is down.`,
|
|
4167
|
+
`no-autoupdate: true`,
|
|
4168
|
+
`loglevel: debug`,
|
|
4169
|
+
``
|
|
4170
|
+
].join("\n");
|
|
4171
|
+
await writeFile(configPath, configBody, "utf8");
|
|
4172
|
+
const proc = spawn(
|
|
4173
|
+
"cloudflared",
|
|
4174
|
+
["tunnel", "--config", configPath, "--url", targetUrl],
|
|
4175
|
+
{ stdio: ["ignore", "pipe", "pipe"], shell: false }
|
|
4176
|
+
);
|
|
4177
|
+
child = proc;
|
|
4178
|
+
const url = await new Promise((resolve7, reject) => {
|
|
4179
|
+
let settled = false;
|
|
4180
|
+
const timer = setTimeout(() => {
|
|
4181
|
+
if (settled) return;
|
|
4182
|
+
settled = true;
|
|
4183
|
+
try {
|
|
4184
|
+
proc.kill("SIGTERM");
|
|
4185
|
+
} catch {
|
|
4186
|
+
}
|
|
4187
|
+
reject(
|
|
4188
|
+
new Error(
|
|
4189
|
+
`cloudflared did not emit a public URL within ${STARTUP_TIMEOUT_MS / 1e3}s`
|
|
4190
|
+
)
|
|
4191
|
+
);
|
|
4192
|
+
}, STARTUP_TIMEOUT_MS);
|
|
4193
|
+
const onData = (chunk) => {
|
|
4194
|
+
const text3 = chunk.toString("utf8");
|
|
4195
|
+
for (const line of text3.split(/\r?\n/)) {
|
|
4196
|
+
if (line.length > 0) opts.onLog?.(`[cloudflared] ${line}`);
|
|
4197
|
+
}
|
|
4198
|
+
if (settled) return;
|
|
4199
|
+
const match = text3.match(URL_REGEX);
|
|
4200
|
+
if (match) {
|
|
4201
|
+
settled = true;
|
|
4202
|
+
clearTimeout(timer);
|
|
4203
|
+
resolve7(match[0]);
|
|
4204
|
+
}
|
|
4205
|
+
};
|
|
4206
|
+
proc.stderr?.on("data", onData);
|
|
4207
|
+
proc.stdout?.on("data", onData);
|
|
4208
|
+
proc.once("error", (err) => {
|
|
4209
|
+
if (settled) return;
|
|
4210
|
+
settled = true;
|
|
4211
|
+
clearTimeout(timer);
|
|
4212
|
+
reject(err);
|
|
4213
|
+
});
|
|
4214
|
+
proc.once("exit", (code, signal) => {
|
|
4215
|
+
if (settled) return;
|
|
4216
|
+
settled = true;
|
|
4217
|
+
clearTimeout(timer);
|
|
4218
|
+
reject(
|
|
4219
|
+
new Error(
|
|
4220
|
+
`cloudflared exited before emitting a URL (code=${code}, signal=${signal})`
|
|
4221
|
+
)
|
|
4222
|
+
);
|
|
4223
|
+
});
|
|
4224
|
+
});
|
|
4225
|
+
proc.once("exit", (code, signal) => {
|
|
4226
|
+
opts.onLog?.(
|
|
4227
|
+
`[cloudflared] exited (code=${code ?? "?"} signal=${signal ?? "-"})`
|
|
4228
|
+
);
|
|
4229
|
+
});
|
|
4230
|
+
return { publicUrl: url, pid: proc.pid ?? null };
|
|
4231
|
+
},
|
|
4232
|
+
async stop() {
|
|
4233
|
+
const proc = child;
|
|
4234
|
+
const cfg = configPath;
|
|
4235
|
+
child = null;
|
|
4236
|
+
configPath = null;
|
|
4237
|
+
if (cfg) {
|
|
4238
|
+
try {
|
|
4239
|
+
await rm(cfg, { force: true });
|
|
4240
|
+
} catch {
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
if (!proc || proc.exitCode !== null) return;
|
|
4244
|
+
proc.kill("SIGTERM");
|
|
4245
|
+
const timer = setTimeout(() => {
|
|
4246
|
+
try {
|
|
4247
|
+
proc.kill("SIGKILL");
|
|
4248
|
+
} catch {
|
|
4249
|
+
}
|
|
4250
|
+
}, 3e3);
|
|
4251
|
+
timer.unref();
|
|
4252
|
+
await new Promise((resolve7) => {
|
|
4253
|
+
if (proc.exitCode !== null) {
|
|
4254
|
+
clearTimeout(timer);
|
|
4255
|
+
resolve7();
|
|
4256
|
+
return;
|
|
4257
|
+
}
|
|
4258
|
+
proc.once("exit", () => {
|
|
4259
|
+
clearTimeout(timer);
|
|
4260
|
+
resolve7();
|
|
4261
|
+
});
|
|
4262
|
+
});
|
|
4263
|
+
}
|
|
4264
|
+
};
|
|
4265
|
+
}
|
|
4266
|
+
async function assertCloudflaredOnPath() {
|
|
4267
|
+
try {
|
|
4268
|
+
await execFileAsync("cloudflared", ["--version"], { timeout: 3e3 });
|
|
4269
|
+
} catch {
|
|
4270
|
+
throw new Error(
|
|
4271
|
+
"cloudflared not found on PATH. Install it first:\n macOS: brew install cloudflared\n Linux: https://pkg.cloudflare.com/index.html\n Windows: winget install --id Cloudflare.cloudflared\nOr download a prebuilt binary from https://github.com/cloudflare/cloudflared/releases."
|
|
4272
|
+
);
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
var RemoteController = class {
|
|
4276
|
+
state = null;
|
|
4277
|
+
bearerToken = null;
|
|
4278
|
+
provider = null;
|
|
4279
|
+
lastError;
|
|
4280
|
+
opts;
|
|
4281
|
+
constructor(opts) {
|
|
4282
|
+
this.opts = opts;
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Auth getter handed to startHttpServer. The HTTP server calls this
|
|
4286
|
+
* on every request so flipping enable/disable is effective without
|
|
4287
|
+
* a restart.
|
|
4288
|
+
*/
|
|
4289
|
+
readAuth = () => {
|
|
4290
|
+
if (this.bearerToken === null) return { mode: "none" };
|
|
4291
|
+
return { mode: "bearer", token: this.bearerToken };
|
|
4292
|
+
};
|
|
4293
|
+
status() {
|
|
4294
|
+
if (!this.state) {
|
|
4295
|
+
return { enabled: false, lastError: this.lastError };
|
|
4296
|
+
}
|
|
4297
|
+
return {
|
|
4298
|
+
enabled: true,
|
|
4299
|
+
provider: this.state.provider,
|
|
4300
|
+
publicUrl: this.state.publicUrl,
|
|
4301
|
+
pid: this.state.pid,
|
|
4302
|
+
createdAt: this.state.createdAt,
|
|
4303
|
+
target: this.state.target,
|
|
4304
|
+
exposesGateway: this.state.exposesGateway,
|
|
4305
|
+
lastError: this.lastError
|
|
4306
|
+
};
|
|
4307
|
+
}
|
|
4308
|
+
async enable(input2) {
|
|
4309
|
+
if (this.state) {
|
|
4310
|
+
throw new Error(
|
|
4311
|
+
"remote tunnel already active \u2014 call remote_disable first to rotate"
|
|
4312
|
+
);
|
|
4313
|
+
}
|
|
4314
|
+
const providerId = input2.provider ?? "quick";
|
|
4315
|
+
const provider = pickProvider(providerId);
|
|
4316
|
+
this.lastError = void 0;
|
|
4317
|
+
const target = {
|
|
4318
|
+
host: input2.targetHost ?? "127.0.0.1",
|
|
4319
|
+
port: input2.targetPort ?? this.opts.port
|
|
4320
|
+
};
|
|
4321
|
+
const exposesGateway = target.host === "127.0.0.1" && target.port === this.opts.port;
|
|
4322
|
+
let started;
|
|
4323
|
+
try {
|
|
4324
|
+
started = await provider.start({
|
|
4325
|
+
target,
|
|
4326
|
+
workspace: this.opts.workspace,
|
|
4327
|
+
onLog: (line) => {
|
|
4328
|
+
this.opts.onLog?.(line);
|
|
4329
|
+
}
|
|
4330
|
+
});
|
|
4331
|
+
} catch (err) {
|
|
4332
|
+
this.lastError = errToMessage(err);
|
|
4333
|
+
throw err;
|
|
4334
|
+
}
|
|
4335
|
+
let bearerToken = null;
|
|
4336
|
+
let bearerTokenHash = "";
|
|
4337
|
+
if (exposesGateway) {
|
|
4338
|
+
bearerToken = randomBytes(32).toString("base64url");
|
|
4339
|
+
bearerTokenHash = sha256(bearerToken);
|
|
4340
|
+
}
|
|
4341
|
+
const state = {
|
|
4342
|
+
provider: providerId,
|
|
4343
|
+
publicUrl: started.publicUrl,
|
|
4344
|
+
bearerTokenHash,
|
|
4345
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4346
|
+
pid: started.pid,
|
|
4347
|
+
target,
|
|
4348
|
+
exposesGateway
|
|
4349
|
+
};
|
|
4350
|
+
this.state = state;
|
|
4351
|
+
this.bearerToken = bearerToken;
|
|
4352
|
+
this.provider = provider;
|
|
4353
|
+
await persistState(this.opts.workspace, state);
|
|
4354
|
+
const result = {
|
|
4355
|
+
provider: providerId,
|
|
4356
|
+
publicUrl: started.publicUrl,
|
|
4357
|
+
target,
|
|
4358
|
+
exposesGateway
|
|
4359
|
+
};
|
|
4360
|
+
if (exposesGateway && bearerToken) {
|
|
4361
|
+
const mcpEndpoint = `${started.publicUrl}/mcp`;
|
|
4362
|
+
result.bearerToken = bearerToken;
|
|
4363
|
+
result.mcpEndpoint = mcpEndpoint;
|
|
4364
|
+
result.mcpConfigSnippet = buildMcpConfigSnippet(mcpEndpoint, bearerToken);
|
|
4365
|
+
}
|
|
4366
|
+
return result;
|
|
4367
|
+
}
|
|
4368
|
+
async disable() {
|
|
4369
|
+
if (!this.state) return { disabled: false };
|
|
4370
|
+
const provider = this.provider;
|
|
4371
|
+
this.state = null;
|
|
4372
|
+
this.bearerToken = null;
|
|
4373
|
+
this.provider = null;
|
|
4374
|
+
if (provider) {
|
|
4375
|
+
try {
|
|
4376
|
+
await provider.stop();
|
|
4377
|
+
} catch (err) {
|
|
4378
|
+
this.lastError = errToMessage(err);
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
await clearState(this.opts.workspace);
|
|
4382
|
+
return { disabled: true };
|
|
4383
|
+
}
|
|
4384
|
+
/**
|
|
4385
|
+
* Stop the provider on gateway shutdown. Does NOT clear persisted
|
|
4386
|
+
* state — that survives across restarts so the user can `remote_status`
|
|
4387
|
+
* after rebooting and see "stale state, run remote_enable to refresh".
|
|
4388
|
+
* (Stale-recovery semantics land in v2; today we just leave the file.)
|
|
4389
|
+
*/
|
|
4390
|
+
async shutdown() {
|
|
4391
|
+
if (this.provider) {
|
|
4392
|
+
try {
|
|
4393
|
+
await this.provider.stop();
|
|
4394
|
+
} catch {
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
};
|
|
4399
|
+
function pickProvider(id) {
|
|
4400
|
+
if (id === "quick") return quickTunnelProvider();
|
|
4401
|
+
const _exhaustive = id;
|
|
4402
|
+
throw new Error(`unknown remote provider: ${String(_exhaustive)}`);
|
|
4403
|
+
}
|
|
4404
|
+
function sha256(input2) {
|
|
4405
|
+
return createHash("sha256").update(input2).digest("hex");
|
|
4406
|
+
}
|
|
4407
|
+
function errToMessage(err) {
|
|
4408
|
+
return err instanceof Error ? err.message : String(err);
|
|
4409
|
+
}
|
|
4410
|
+
async function persistState(workspace, state) {
|
|
4411
|
+
const dir = join(workspace, ".agentproto");
|
|
4412
|
+
await mkdir(dir, { recursive: true });
|
|
4413
|
+
const file = join(dir, "remote.json");
|
|
4414
|
+
await writeFile(file, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
4415
|
+
try {
|
|
4416
|
+
await chmod(file, 384);
|
|
4417
|
+
} catch {
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
async function clearState(workspace) {
|
|
4421
|
+
const file = join(workspace, ".agentproto/remote.json");
|
|
4422
|
+
if (!existsSync(file)) return;
|
|
4423
|
+
await rm(file, { force: true });
|
|
4424
|
+
}
|
|
4425
|
+
function buildMcpConfigSnippet(endpoint, token) {
|
|
4426
|
+
return JSON.stringify(
|
|
4427
|
+
{
|
|
4428
|
+
mcpServers: {
|
|
4429
|
+
"agentproto-remote": {
|
|
4430
|
+
transport: "http",
|
|
4431
|
+
url: endpoint,
|
|
4432
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
},
|
|
4436
|
+
null,
|
|
4437
|
+
2
|
|
4438
|
+
);
|
|
4439
|
+
}
|
|
4440
|
+
function text2(value) {
|
|
4441
|
+
return {
|
|
4442
|
+
content: [
|
|
4443
|
+
{
|
|
4444
|
+
type: "text",
|
|
4445
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
4446
|
+
}
|
|
4447
|
+
]
|
|
4448
|
+
};
|
|
4449
|
+
}
|
|
4450
|
+
function registerRemoteTools(server, opts) {
|
|
4451
|
+
const { controller } = opts;
|
|
4452
|
+
server.tool(
|
|
4453
|
+
"remote_enable",
|
|
4454
|
+
"Publish a local port to the internet via Cloudflare. By default exposes this gateway and gates it with a bearer token (returned ONCE in the response \u2014 store it; only its hash is persisted). Pass `targetPort` to tunnel a different local service (Supabase, dev server, \u2026); in that mode the daemon does not gate the proxied traffic \u2014 the upstream service handles its own auth. Returns the public URL plus a paste-ready `.mcp.json` snippet (gateway mode only). Re-running while a tunnel is already up errors; call `remote_disable` first to rotate.",
|
|
4455
|
+
{
|
|
4456
|
+
provider: z.enum(["quick"]).optional().describe(
|
|
4457
|
+
"Tunnel backend. `quick` = Cloudflare Quick Tunnel (no API key, ephemeral *.trycloudflare.com URL). Defaults to `quick`. Named-CF / Tailscale providers TBD."
|
|
4458
|
+
),
|
|
4459
|
+
targetPort: z.number().int().min(1).max(65535).optional().describe(
|
|
4460
|
+
"Local port to expose. Defaults to this gateway's own port (= publish the MCP server). Pass another port to tunnel a different local service (e.g. 54371 for Supabase) \u2014 in that mode bearer auth is OFF since the upstream handles its own gating."
|
|
4461
|
+
),
|
|
4462
|
+
targetHost: z.string().optional().describe(
|
|
4463
|
+
"Host the tunnel forwards to. Defaults to `127.0.0.1`. Use `localhost` only if the target is explicitly IPv6-bound; cloudflared resolves DNS and IPv6-first lookups can break IPv4-only origins."
|
|
4464
|
+
)
|
|
4465
|
+
},
|
|
4466
|
+
async ({ provider, targetPort, targetHost }) => {
|
|
4467
|
+
const result = await controller.enable({ provider, targetPort, targetHost });
|
|
4468
|
+
return text2(result);
|
|
4469
|
+
}
|
|
4470
|
+
);
|
|
4471
|
+
server.tool(
|
|
4472
|
+
"remote_disable",
|
|
4473
|
+
"Tear down the active remote tunnel and drop bearer auth back to loopback-only. Idempotent \u2014 calling when no tunnel is active is a no-op.",
|
|
4474
|
+
{},
|
|
4475
|
+
async () => {
|
|
4476
|
+
const result = await controller.disable();
|
|
4477
|
+
return text2(result);
|
|
4478
|
+
}
|
|
4479
|
+
);
|
|
4480
|
+
server.tool(
|
|
4481
|
+
"remote_status",
|
|
4482
|
+
"Report whether a remote tunnel is active. When active: provider, public URL, supervised PID, createdAt. The bearer token itself is never returned by status \u2014 it was shown once at enable.",
|
|
4483
|
+
{},
|
|
4484
|
+
async () => {
|
|
4485
|
+
return text2(controller.status());
|
|
4486
|
+
}
|
|
4487
|
+
);
|
|
4488
|
+
}
|
|
4489
|
+
var WorkspacePathError = class extends Error {
|
|
4490
|
+
constructor(message) {
|
|
4491
|
+
super(message);
|
|
4492
|
+
this.name = "WorkspacePathError";
|
|
4493
|
+
}
|
|
4494
|
+
};
|
|
4495
|
+
function createWorkspaceFs(opts) {
|
|
4496
|
+
const root = resolve(opts.workspace);
|
|
4497
|
+
function resolvePath32(path) {
|
|
4498
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
4499
|
+
throw new WorkspacePathError("path must be a non-empty string");
|
|
4500
|
+
}
|
|
4501
|
+
if (isAbsolute(path)) {
|
|
4502
|
+
throw new WorkspacePathError(
|
|
4503
|
+
"absolute paths are not allowed; use a workspace-relative path"
|
|
4504
|
+
);
|
|
4505
|
+
}
|
|
4506
|
+
const joined = normalize(join(root, path));
|
|
4507
|
+
const rel = relative(root, joined);
|
|
4508
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
4509
|
+
throw new WorkspacePathError(
|
|
4510
|
+
`path escapes the workspace: '${path}'`
|
|
4511
|
+
);
|
|
4512
|
+
}
|
|
4513
|
+
return joined;
|
|
4514
|
+
}
|
|
4515
|
+
return {
|
|
4516
|
+
async readFile(path) {
|
|
4517
|
+
const abs = resolvePath32(path);
|
|
4518
|
+
const buf = await readFile(abs);
|
|
4519
|
+
return buf.toString("utf8");
|
|
4520
|
+
},
|
|
4521
|
+
async writeFile(path, content) {
|
|
4522
|
+
const abs = resolvePath32(path);
|
|
4523
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
4524
|
+
await writeFile(abs, content);
|
|
4525
|
+
},
|
|
4526
|
+
async exists(path) {
|
|
4527
|
+
try {
|
|
4528
|
+
const abs = resolvePath32(path);
|
|
4529
|
+
return existsSync(abs);
|
|
4530
|
+
} catch {
|
|
4531
|
+
return false;
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
};
|
|
4535
|
+
}
|
|
4536
|
+
async function createGateway(opts) {
|
|
4537
|
+
const workspace = resolve(opts.workspace);
|
|
4538
|
+
if (!existsSync(workspace)) {
|
|
4539
|
+
throw new Error(`runtime: workspace dir does not exist: ${workspace}`);
|
|
4540
|
+
}
|
|
4541
|
+
const port = opts.port ?? 18790;
|
|
4542
|
+
const events = createRuntimeEvents();
|
|
4543
|
+
const conversations = fileConversationStore({ workspace });
|
|
4544
|
+
const workspaceFs = createWorkspaceFs({ workspace });
|
|
4545
|
+
const remote = new RemoteController({
|
|
4546
|
+
workspace,
|
|
4547
|
+
port,
|
|
4548
|
+
onLog: (line) => events.emit({
|
|
4549
|
+
type: "remote-log",
|
|
4550
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4551
|
+
line
|
|
4552
|
+
})
|
|
4553
|
+
});
|
|
4554
|
+
const { registered } = await createMcpServer({
|
|
4555
|
+
specs: opts.specs,
|
|
4556
|
+
workspace,
|
|
4557
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4558
|
+
version: opts.version ?? "0.1.0-alpha"
|
|
4559
|
+
});
|
|
4560
|
+
const sessions = createSessionsRegistry();
|
|
4561
|
+
const mcpProxy = new McpProxyRegistry();
|
|
4562
|
+
const mcpServerFactory = async () => {
|
|
4563
|
+
const { server } = await createMcpServer({
|
|
4564
|
+
specs: opts.specs,
|
|
4565
|
+
workspace,
|
|
4566
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4567
|
+
version: opts.version ?? "0.1.0-alpha"
|
|
4568
|
+
});
|
|
4569
|
+
registerFsTools(server, { workspace });
|
|
4570
|
+
registerCommandTools(server, { workspace });
|
|
4571
|
+
registerRemoteTools(server, { controller: remote });
|
|
4572
|
+
registerSessionTools(server, {
|
|
4573
|
+
registry: sessions,
|
|
4574
|
+
mcpProxy,
|
|
4575
|
+
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4576
|
+
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {}
|
|
4577
|
+
});
|
|
4578
|
+
return server;
|
|
4579
|
+
};
|
|
4580
|
+
if (opts.boot !== false) {
|
|
4581
|
+
await runBoot(workspace, opts, conversations, events).catch((err) => {
|
|
4582
|
+
events.emit({
|
|
4583
|
+
type: "heartbeat-error",
|
|
4584
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4585
|
+
agent: opts.defaultBootAgent,
|
|
4586
|
+
error: `BOOT.md failed: ${err instanceof Error ? err.message : String(err)}`
|
|
4587
|
+
});
|
|
4588
|
+
});
|
|
4589
|
+
}
|
|
4590
|
+
const heartbeat = startHeartbeat({
|
|
4591
|
+
workspace,
|
|
4592
|
+
conversations,
|
|
4593
|
+
events,
|
|
4594
|
+
buildAgent: opts.buildAgent ?? noopBuildAgent
|
|
4595
|
+
});
|
|
4596
|
+
const http = await startHttpServer({
|
|
4597
|
+
port,
|
|
4598
|
+
bind: opts.bind,
|
|
4599
|
+
// Auth is read on every request via this getter. `opts.auth`
|
|
4600
|
+
// (when provided) is the startup default and represents the
|
|
4601
|
+
// operator's intent — bearer-only deployments stay bearer-only
|
|
4602
|
+
// even when no remote tunnel is up. The controller's auth wins
|
|
4603
|
+
// once a tunnel is enabled (it issues a fresh token); on disable
|
|
4604
|
+
// we fall back to `opts.auth` if set, otherwise `mode: "none"`.
|
|
4605
|
+
auth: () => {
|
|
4606
|
+
const remoteAuth = remote.readAuth();
|
|
4607
|
+
if (remoteAuth.mode === "bearer") return remoteAuth;
|
|
4608
|
+
return opts.auth ?? { mode: "none" };
|
|
4609
|
+
},
|
|
4610
|
+
mcpServerFactory,
|
|
4611
|
+
conversations,
|
|
4612
|
+
events,
|
|
4613
|
+
heartbeat,
|
|
4614
|
+
sessions,
|
|
4615
|
+
mcpProxy,
|
|
4616
|
+
...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
|
|
4617
|
+
...opts.listAgentAdapters ? { listAgentAdapters: opts.listAgentAdapters } : {},
|
|
4618
|
+
meta: { workspace, registered }
|
|
4619
|
+
});
|
|
4620
|
+
heartbeat.start();
|
|
4621
|
+
events.emit({
|
|
4622
|
+
type: "boot",
|
|
4623
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4624
|
+
workspace,
|
|
4625
|
+
registered
|
|
4626
|
+
});
|
|
4627
|
+
void writeRuntimeMeta(workspace, {
|
|
4628
|
+
workspace,
|
|
4629
|
+
port,
|
|
4630
|
+
bind: opts.bind ?? "127.0.0.1",
|
|
4631
|
+
pid: process.pid,
|
|
4632
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4633
|
+
name: opts.name ?? "agentproto-runtime",
|
|
4634
|
+
registered
|
|
4635
|
+
});
|
|
4636
|
+
return {
|
|
4637
|
+
url: http.url,
|
|
4638
|
+
workspace,
|
|
4639
|
+
workspaceFs,
|
|
4640
|
+
registered,
|
|
4641
|
+
sessions,
|
|
4642
|
+
async stop() {
|
|
4643
|
+
heartbeat.stop();
|
|
4644
|
+
sessions.shutdown();
|
|
4645
|
+
await mcpProxy.closeAll();
|
|
4646
|
+
await remote.shutdown();
|
|
4647
|
+
await http.stop();
|
|
4648
|
+
}
|
|
4649
|
+
};
|
|
4650
|
+
}
|
|
4651
|
+
var noopBuildAgent = async () => null;
|
|
4652
|
+
async function runBoot(workspace, opts, conversations, events) {
|
|
4653
|
+
const path = join(workspace, "BOOT.md");
|
|
4654
|
+
if (!existsSync(path)) return;
|
|
4655
|
+
const body = (await readFile(path, "utf8")).trim();
|
|
4656
|
+
if (!body) return;
|
|
4657
|
+
if (!opts.buildAgent || !opts.defaultBootAgent) return;
|
|
4658
|
+
const agent = await opts.buildAgent(opts.defaultBootAgent);
|
|
4659
|
+
if (!agent) return;
|
|
4660
|
+
const conversationId = `boot-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:.]/g, "-")}`;
|
|
4661
|
+
await conversations.open(conversationId, { agent: opts.defaultBootAgent });
|
|
4662
|
+
await conversations.appendTurn(conversationId, "user", body, {
|
|
4663
|
+
attribution: "boot"
|
|
4664
|
+
});
|
|
4665
|
+
const reply = await agent.generate(body);
|
|
4666
|
+
await conversations.appendTurn(conversationId, "assistant", reply.text, {
|
|
4667
|
+
attribution: opts.defaultBootAgent
|
|
4668
|
+
});
|
|
4669
|
+
events.emit({
|
|
4670
|
+
type: "heartbeat-fired",
|
|
4671
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4672
|
+
agent: opts.defaultBootAgent,
|
|
4673
|
+
conversationId,
|
|
4674
|
+
prompt: body,
|
|
4675
|
+
reply: reply.text,
|
|
4676
|
+
durationMs: 0
|
|
4677
|
+
});
|
|
4678
|
+
}
|
|
1011
4679
|
async function runServe(args) {
|
|
1012
4680
|
const { values } = parseArgs({
|
|
1013
4681
|
args: [...args],
|
|
@@ -1024,8 +4692,8 @@ async function runServe(args) {
|
|
|
1024
4692
|
});
|
|
1025
4693
|
const workspace = resolve(values.workspace ?? process.cwd());
|
|
1026
4694
|
try {
|
|
1027
|
-
const
|
|
1028
|
-
if (!
|
|
4695
|
+
const stat2 = await promises.stat(workspace);
|
|
4696
|
+
if (!stat2.isDirectory()) {
|
|
1029
4697
|
process.stderr.write(
|
|
1030
4698
|
`agentproto serve: --workspace "${workspace}" is not a directory.
|
|
1031
4699
|
`
|
|
@@ -1139,8 +4807,8 @@ agentproto serve: ${signal} \u2014 shutting down.
|
|
|
1139
4807
|
`agentproto serve: running local-only (no --connect). Press Ctrl-C to stop.
|
|
1140
4808
|
`
|
|
1141
4809
|
);
|
|
1142
|
-
await new Promise((
|
|
1143
|
-
aborter.signal.addEventListener("abort", () =>
|
|
4810
|
+
await new Promise((resolve3) => {
|
|
4811
|
+
aborter.signal.addEventListener("abort", () => resolve3());
|
|
1144
4812
|
});
|
|
1145
4813
|
return 0;
|
|
1146
4814
|
}
|
|
@@ -1175,10 +4843,10 @@ async function runOneTunnel(opts, gateway, signal) {
|
|
|
1175
4843
|
};
|
|
1176
4844
|
if (opts.token) headers.authorization = `Bearer ${opts.token}`;
|
|
1177
4845
|
const ws = new WebSocket(opts.connect, { headers });
|
|
1178
|
-
await new Promise((
|
|
4846
|
+
await new Promise((resolve3, reject) => {
|
|
1179
4847
|
const onOpen = () => {
|
|
1180
4848
|
ws.off("error", onError);
|
|
1181
|
-
|
|
4849
|
+
resolve3();
|
|
1182
4850
|
};
|
|
1183
4851
|
const onError = (err) => {
|
|
1184
4852
|
ws.off("open", onOpen);
|
|
@@ -1223,28 +4891,28 @@ async function runOneTunnel(opts, gateway, signal) {
|
|
|
1223
4891
|
});
|
|
1224
4892
|
}
|
|
1225
4893
|
});
|
|
1226
|
-
await new Promise((
|
|
4894
|
+
await new Promise((resolve3) => {
|
|
1227
4895
|
const offClose = sink.onClose(() => {
|
|
1228
4896
|
offClose();
|
|
1229
|
-
|
|
4897
|
+
resolve3();
|
|
1230
4898
|
});
|
|
1231
4899
|
if (signal.aborted) {
|
|
1232
|
-
server.close().finally(() =>
|
|
4900
|
+
server.close().finally(() => resolve3());
|
|
1233
4901
|
}
|
|
1234
4902
|
signal.addEventListener("abort", () => {
|
|
1235
|
-
server.close().finally(() =>
|
|
4903
|
+
server.close().finally(() => resolve3());
|
|
1236
4904
|
});
|
|
1237
4905
|
});
|
|
1238
4906
|
process.stderr.write(`agentproto serve: tunnel closed.
|
|
1239
4907
|
`);
|
|
1240
4908
|
}
|
|
1241
4909
|
function sleep(ms, signal) {
|
|
1242
|
-
return new Promise((
|
|
1243
|
-
if (signal.aborted) return
|
|
1244
|
-
const timer = setTimeout(
|
|
4910
|
+
return new Promise((resolve3) => {
|
|
4911
|
+
if (signal.aborted) return resolve3();
|
|
4912
|
+
const timer = setTimeout(resolve3, ms);
|
|
1245
4913
|
signal.addEventListener("abort", () => {
|
|
1246
4914
|
clearTimeout(timer);
|
|
1247
|
-
|
|
4915
|
+
resolve3();
|
|
1248
4916
|
});
|
|
1249
4917
|
});
|
|
1250
4918
|
}
|