shippie
Version:
an extensible code review agent
1,410 lines (1,400 loc) ⢠82.6 kB
JavaScript
import { createRequire } from "node:module";
import { local, sqlite } from "@flue/runtime/node";
import { Bash, InMemoryFs, bashFactoryToSessionEnv, configureFlueRuntime, createDefaultFlueApp, createFlueContext, createNodeAgentCoordinator, createNodeDispatchQueue, generateWorkflowRunId, invokeDirectAttached, invokeWorkflowAttached, resolveModel } from "@flue/runtime/internal";
import { connectMcpServer, createAgent, defineTool, dispatch } from "@flue/runtime";
import { createHash, randomUUID } from "node:crypto";
import { createGitHubChannel } from "@flue/github";
import { Octokit } from "octokit";
import * as v from "valibot";
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { isAbsolute, join, relative } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import picomatch from "picomatch";
//#region \0rolldown/runtime.js
var __defProp = Object.defineProperty;
var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
var __exportAll = (all, no_symbols) => {
let target = {};
for (var name in all) __defProp(target, name, {
get: all[name],
enumerable: true
});
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
return target;
};
var __require = /* @__PURE__ */ createRequire(import.meta.url);
//#endregion
//#region \0virtual:flue/packaged-skills
var packagedSkills$1 = /* @__PURE__ */ new Map();
function getPackagedSkills() {
return Object.fromEntries(packagedSkills$1);
}
//#endregion
//#region node_modules/@hono/node-server/dist/constants-BXAKTxRC.cjs
var require_constants_BXAKTxRC = /* @__PURE__ */ __commonJSMin(((exports) => {
var X_ALREADY_SENT = "x-hono-already-sent";
Object.defineProperty(exports, "X_ALREADY_SENT", {
enumerable: true,
get: function() {
return X_ALREADY_SENT;
}
});
}));
//#endregion
//#region node_modules/hono/dist/cjs/helper/websocket/index.js
var require_websocket = /* @__PURE__ */ __commonJSMin(((exports, module) => {
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all) __defProp(target, name, {
get: all[name],
enumerable: true
});
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var websocket_exports = {};
__export(websocket_exports, {
WSContext: () => WSContext,
createWSMessageEvent: () => createWSMessageEvent,
defineWebSocketHelper: () => defineWebSocketHelper
});
module.exports = __toCommonJS(websocket_exports);
var WSContext = class {
#init;
constructor(init) {
this.#init = init;
this.raw = init.raw;
this.url = init.url ? new URL(init.url) : null;
this.protocol = init.protocol ?? null;
}
send(source, options) {
this.#init.send(source, options ?? {});
}
raw;
binaryType = "arraybuffer";
get readyState() {
return this.#init.readyState;
}
url;
protocol;
close(code, reason) {
this.#init.close(code, reason);
}
};
var createWSMessageEvent = (source) => {
return new MessageEvent("message", { data: source });
};
var defineWebSocketHelper = (handler) => {
return ((...args) => {
if (typeof args[0] === "function") {
const [createEvents, options] = args;
return async function upgradeWebSocket(c, next) {
const result = await handler(c, await createEvents(c), options);
if (result) return result;
await next();
};
} else {
const [c, events, options] = args;
return (async () => {
const upgraded = await handler(c, events, options);
if (!upgraded) throw new Error("Failed to upgrade WebSocket");
return upgraded;
})();
}
});
};
0 && (module.exports = {
WSContext,
createWSMessageEvent,
defineWebSocketHelper
});
}));
//#endregion
//#region src/channels/github.ts
var import_dist = (/* @__PURE__ */ __commonJSMin(((exports) => {
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var require_constants = require_constants_BXAKTxRC();
var node_http = __require("node:http");
var node_http2 = __require("node:http2");
var node_stream = __require("node:stream");
var hono_ws = require_websocket();
var RequestError = class extends Error {
constructor(message, options) {
super(message, options);
this.name = "RequestError";
}
};
var reValidRequestUrl = /^\/[!#$&-;=?-\[\]_a-z~]*$/;
var reDotSegment = /\/\.\.?(?:[/?#]|$)/;
var reValidHost = /^[a-z0-9._-]+(?::(?:[1-5]\d{3,4}|[6-9]\d{3}))?$/;
var buildUrl = (scheme, host, incomingUrl) => {
const url = `${scheme}://${host}${incomingUrl}`;
if (!reValidHost.test(host)) {
const urlObj = new URL(url);
if (urlObj.hostname.length !== host.length && urlObj.hostname !== (host.includes(":") ? host.replace(/:\d+$/, "") : host).toLowerCase()) throw new RequestError("Invalid host header");
return urlObj.href;
} else if (incomingUrl.length === 0) return url + "/";
else {
if (incomingUrl.charCodeAt(0) !== 47) throw new RequestError("Invalid URL");
if (!reValidRequestUrl.test(incomingUrl) || reDotSegment.test(incomingUrl)) return new URL(url).href;
return url;
}
};
var toRequestError = (e) => {
if (e instanceof RequestError) return e;
return new RequestError(e.message, { cause: e });
};
var GlobalRequest = global.Request;
var Request$1 = class extends GlobalRequest {
constructor(input, options) {
if (typeof input === "object" && getRequestCache in input) {
const hasReplacementBody = options !== void 0 && "body" in options && options.body != null;
if (input[bodyConsumedDirectlyKey] && !hasReplacementBody) throw new TypeError("Cannot construct a Request with a Request object that has already been used.");
input = input[getRequestCache]();
}
if (typeof (options?.body)?.getReader !== "undefined") options.duplex ??= "half";
super(input, options);
}
};
var newHeadersFromIncoming = (incoming) => {
const headerRecord = [];
const rawHeaders = incoming.rawHeaders;
for (let i = 0, len = rawHeaders.length; i < len; i += 2) {
const key = rawHeaders[i];
if (key.charCodeAt(0) !== 58) headerRecord.push([key, rawHeaders[i + 1]]);
}
return new Headers(headerRecord);
};
var wrapBodyStream = Symbol("wrapBodyStream");
var newRequestFromIncoming = (method, url, headers, incoming, abortController) => {
const init = {
method,
headers,
signal: abortController.signal
};
if (method === "TRACE") {
init.method = "GET";
const req = new Request$1(url, init);
Object.defineProperty(req, "method", { get() {
return "TRACE";
} });
return req;
}
if (!(method === "GET" || method === "HEAD")) if ("rawBody" in incoming && incoming.rawBody instanceof Buffer) init.body = new ReadableStream({ start(controller) {
controller.enqueue(incoming.rawBody);
controller.close();
} });
else if (incoming[wrapBodyStream]) {
let reader;
init.body = new ReadableStream({ async pull(controller) {
try {
reader ||= node_stream.Readable.toWeb(incoming).getReader();
const { done, value } = await reader.read();
if (done) controller.close();
else controller.enqueue(value);
} catch (error) {
controller.error(error);
}
} });
} else init.body = node_stream.Readable.toWeb(incoming);
return new Request$1(url, init);
};
var getRequestCache = Symbol("getRequestCache");
var requestCache = Symbol("requestCache");
var incomingKey = Symbol("incomingKey");
var urlKey = Symbol("urlKey");
var methodKey = Symbol("methodKey");
var headersKey = Symbol("headersKey");
var abortControllerKey = Symbol("abortControllerKey");
var getAbortController = Symbol("getAbortController");
var abortRequest = Symbol("abortRequest");
var bodyBufferKey = Symbol("bodyBuffer");
var bodyReadPromiseKey = Symbol("bodyReadPromise");
var bodyConsumedDirectlyKey = Symbol("bodyConsumedDirectly");
var bodyLockReaderKey = Symbol("bodyLockReader");
var abortReasonKey = Symbol("abortReason");
var newBodyUnusableError = () => {
return /* @__PURE__ */ new TypeError("Body is unusable");
};
var rejectBodyUnusable = () => {
return Promise.reject(newBodyUnusableError());
};
var textDecoder = new TextDecoder();
var consumeBodyDirectOnce = (request) => {
if (request[bodyConsumedDirectlyKey]) return rejectBodyUnusable();
request[bodyConsumedDirectlyKey] = true;
};
var toArrayBuffer = (buf) => {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
};
var contentType = (request) => {
return (request[headersKey] ||= newHeadersFromIncoming(request[incomingKey])).get("content-type") || "";
};
var methodTokenRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
var normalizeIncomingMethod = (method) => {
if (typeof method !== "string" || method.length === 0) return "GET";
switch (method) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "POST":
case "PUT": return method;
}
const upper = method.toUpperCase();
switch (upper) {
case "DELETE":
case "GET":
case "HEAD":
case "OPTIONS":
case "POST":
case "PUT": return upper;
default: return method;
}
};
var validateDirectReadMethod = (method) => {
if (!methodTokenRegExp.test(method)) return /* @__PURE__ */ new TypeError(`'${method}' is not a valid HTTP method.`);
const normalized = method.toUpperCase();
if (normalized === "CONNECT" || normalized === "TRACK" || normalized === "TRACE" && method !== "TRACE") return /* @__PURE__ */ new TypeError(`'${method}' HTTP method is unsupported.`);
};
var readBodyWithFastPath = (request, method, fromBuffer) => {
if (request[bodyConsumedDirectlyKey]) return rejectBodyUnusable();
const methodName = request.method;
if (methodName === "GET" || methodName === "HEAD") return request[getRequestCache]()[method]();
const methodValidationError = validateDirectReadMethod(methodName);
if (methodValidationError) return Promise.reject(methodValidationError);
if (request[requestCache]) {
if (methodName !== "TRACE") return request[requestCache][method]();
}
const alreadyUsedError = consumeBodyDirectOnce(request);
if (alreadyUsedError) return alreadyUsedError;
const raw = readRawBodyIfAvailable(request);
if (raw) {
const result = Promise.resolve(fromBuffer(raw, request));
request[bodyBufferKey] = void 0;
return result;
}
return readBodyDirect(request).then((buf) => {
const result = fromBuffer(buf, request);
request[bodyBufferKey] = void 0;
return result;
});
};
var readRawBodyIfAvailable = (request) => {
const incoming = request[incomingKey];
if ("rawBody" in incoming && incoming.rawBody instanceof Buffer) return incoming.rawBody;
};
var readBodyDirect = (request) => {
if (request[bodyBufferKey]) return Promise.resolve(request[bodyBufferKey]);
if (request[bodyReadPromiseKey]) return request[bodyReadPromiseKey];
const incoming = request[incomingKey];
if (node_stream.Readable.isDisturbed(incoming)) return rejectBodyUnusable();
const promise = new Promise((resolve, reject) => {
const chunks = [];
let settled = false;
const finish = (callback) => {
if (settled) return;
settled = true;
cleanup();
callback();
};
const onData = (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
};
const onEnd = () => {
finish(() => {
const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
request[bodyBufferKey] = buffer;
resolve(buffer);
});
};
const onError = (error) => {
finish(() => {
reject(error);
});
};
const onClose = () => {
if (incoming.readableEnded) {
onEnd();
return;
}
finish(() => {
if (incoming.errored) {
reject(incoming.errored);
return;
}
const reason = request[abortReasonKey];
if (reason !== void 0) {
reject(reason instanceof Error ? reason : new Error(String(reason)));
return;
}
reject(/* @__PURE__ */ new Error("Client connection prematurely closed."));
});
};
const cleanup = () => {
incoming.off("data", onData);
incoming.off("end", onEnd);
incoming.off("error", onError);
incoming.off("close", onClose);
request[bodyReadPromiseKey] = void 0;
};
incoming.on("data", onData);
incoming.on("end", onEnd);
incoming.on("error", onError);
incoming.on("close", onClose);
queueMicrotask(() => {
if (settled) return;
if (incoming.readableEnded) onEnd();
else if (incoming.errored) onError(incoming.errored);
else if (incoming.destroyed) onClose();
});
});
request[bodyReadPromiseKey] = promise;
return promise;
};
var requestPrototype = {
get method() {
return this[methodKey];
},
get url() {
return this[urlKey];
},
get headers() {
return this[headersKey] ||= newHeadersFromIncoming(this[incomingKey]);
},
[abortRequest](reason) {
if (this[abortReasonKey] === void 0) this[abortReasonKey] = reason;
const abortController = this[abortControllerKey];
if (abortController && !abortController.signal.aborted) abortController.abort(reason);
},
[getAbortController]() {
this[abortControllerKey] ||= new AbortController();
if (this[abortReasonKey] !== void 0 && !this[abortControllerKey].signal.aborted) this[abortControllerKey].abort(this[abortReasonKey]);
return this[abortControllerKey];
},
[getRequestCache]() {
const abortController = this[getAbortController]();
if (this[requestCache]) return this[requestCache];
const method = this.method;
if (this[bodyConsumedDirectlyKey] && !(method === "GET" || method === "HEAD")) {
this[bodyBufferKey] = void 0;
const init = {
method: method === "TRACE" ? "GET" : method,
headers: this.headers,
signal: abortController.signal
};
if (method !== "TRACE") {
init.body = new ReadableStream({ start(c) {
c.close();
} });
init.duplex = "half";
}
const req = new Request$1(this[urlKey], init);
if (method === "TRACE") Object.defineProperty(req, "method", { get() {
return "TRACE";
} });
return this[requestCache] = req;
}
return this[requestCache] = newRequestFromIncoming(this.method, this[urlKey], this.headers, this[incomingKey], abortController);
},
get body() {
if (!this[bodyConsumedDirectlyKey]) return this[getRequestCache]().body;
const request = this[getRequestCache]();
if (!this[bodyLockReaderKey] && request.body) this[bodyLockReaderKey] = request.body.getReader();
return request.body;
},
get bodyUsed() {
if (this[bodyConsumedDirectlyKey]) return true;
if (this[requestCache]) return this[requestCache].bodyUsed;
return false;
}
};
Object.defineProperty(requestPrototype, "signal", { get() {
return this[getAbortController]().signal;
} });
[
"cache",
"credentials",
"destination",
"integrity",
"mode",
"redirect",
"referrer",
"referrerPolicy",
"keepalive"
].forEach((k) => {
Object.defineProperty(requestPrototype, k, { get() {
return this[getRequestCache]()[k];
} });
});
["clone", "formData"].forEach((k) => {
Object.defineProperty(requestPrototype, k, { value: function() {
if (this[bodyConsumedDirectlyKey]) {
if (k === "clone") throw newBodyUnusableError();
return rejectBodyUnusable();
}
return this[getRequestCache]()[k]();
} });
});
Object.defineProperty(requestPrototype, "text", { value: function() {
return readBodyWithFastPath(this, "text", (buf) => textDecoder.decode(buf));
} });
Object.defineProperty(requestPrototype, "arrayBuffer", { value: function() {
return readBodyWithFastPath(this, "arrayBuffer", (buf) => toArrayBuffer(buf));
} });
Object.defineProperty(requestPrototype, "blob", { value: function() {
return readBodyWithFastPath(this, "blob", (buf, request) => {
const type = contentType(request);
return new Response(buf, type ? { headers: { "content-type": type } } : void 0).blob();
});
} });
Object.defineProperty(requestPrototype, "json", { value: function() {
if (this[bodyConsumedDirectlyKey]) return rejectBodyUnusable();
return this.text().then(JSON.parse);
} });
Object.defineProperty(requestPrototype, Symbol.for("nodejs.util.inspect.custom"), { value: function(depth, options, inspectFn) {
return `Request (lightweight) ${inspectFn({
method: this.method,
url: this.url,
headers: this.headers,
nativeRequest: this[requestCache]
}, {
...options,
depth: depth == null ? null : depth - 1
})}`;
} });
Object.setPrototypeOf(requestPrototype, Request$1.prototype);
var newRequest = (incoming, defaultHostname) => {
const req = Object.create(requestPrototype);
req[incomingKey] = incoming;
req[methodKey] = normalizeIncomingMethod(incoming.method);
const incomingUrl = incoming.url || "";
if (incomingUrl[0] !== "/" && (incomingUrl.startsWith("http://") || incomingUrl.startsWith("https://"))) {
if (incoming instanceof node_http2.Http2ServerRequest) throw new RequestError("Absolute URL for :path is not allowed in HTTP/2");
try {
req[urlKey] = new URL(incomingUrl).href;
} catch (e) {
throw new RequestError("Invalid absolute URL", { cause: e });
}
return req;
}
const host = (incoming instanceof node_http2.Http2ServerRequest ? incoming.authority : incoming.headers.host) || defaultHostname;
if (!host) throw new RequestError("Missing host header");
let scheme;
if (incoming instanceof node_http2.Http2ServerRequest) {
scheme = incoming.scheme;
if (!(scheme === "http" || scheme === "https")) throw new RequestError("Unsupported scheme");
} else scheme = incoming.socket && incoming.socket.encrypted ? "https" : "http";
try {
req[urlKey] = buildUrl(scheme, host, incomingUrl);
} catch (e) {
if (e instanceof RequestError) throw e;
else throw new RequestError("Invalid URL", { cause: e });
}
return req;
};
var defaultContentType = "text/plain; charset=UTF-8";
var responseCache = Symbol("responseCache");
var getResponseCache = Symbol("getResponseCache");
var cacheKey = Symbol("cache");
var GlobalResponse = global.Response;
var Response$1 = class Response$1 {
#body;
#init;
[getResponseCache]() {
const cache = this[cacheKey];
const liveHeaders = cache && cache[2] instanceof Headers ? cache[2] : void 0;
delete this[cacheKey];
return this[responseCache] ||= new GlobalResponse(this.#body, liveHeaders ? {
status: this.#init?.status,
statusText: this.#init?.statusText,
headers: liveHeaders
} : this.#init);
}
constructor(body, init) {
let headers;
this.#body = body;
if (init instanceof Response$1) {
const cachedGlobalResponse = init[responseCache];
if (cachedGlobalResponse) {
this.#init = cachedGlobalResponse;
this[getResponseCache]();
return;
} else {
this.#init = init.#init;
headers = new Headers(init.headers);
}
} else this.#init = init;
if (body == null || typeof body === "string" || typeof body?.getReader !== "undefined" || body instanceof Blob || body instanceof Uint8Array) this[cacheKey] = [
init?.status || 200,
body ?? null,
headers || init?.headers
];
}
get headers() {
const cache = this[cacheKey];
if (cache) {
if (!(cache[2] instanceof Headers)) cache[2] = new Headers(cache[2] || (cache[1] === null ? void 0 : { "content-type": defaultContentType }));
return cache[2];
}
return this[getResponseCache]().headers;
}
get status() {
return this[cacheKey]?.[0] ?? this[getResponseCache]().status;
}
get ok() {
const status = this.status;
return status >= 200 && status < 300;
}
};
[
"body",
"bodyUsed",
"redirected",
"statusText",
"trailers",
"type",
"url"
].forEach((k) => {
Object.defineProperty(Response$1.prototype, k, { get() {
return this[getResponseCache]()[k];
} });
});
[
"arrayBuffer",
"blob",
"clone",
"formData",
"json",
"text"
].forEach((k) => {
Object.defineProperty(Response$1.prototype, k, { value: function() {
return this[getResponseCache]()[k]();
} });
});
Object.defineProperty(Response$1.prototype, Symbol.for("nodejs.util.inspect.custom"), { value: function(depth, options, inspectFn) {
return `Response (lightweight) ${inspectFn({
status: this.status,
headers: this.headers,
ok: this.ok,
nativeResponse: this[responseCache]
}, {
...options,
depth: depth == null ? null : depth - 1
})}`;
} });
Object.setPrototypeOf(Response$1, GlobalResponse);
Object.setPrototypeOf(Response$1.prototype, GlobalResponse.prototype);
var validRedirectUrl = /^https?:\/\/[!#-;=?-[\]_a-z~A-Z]+$/;
var parseRedirectUrl = (url) => {
if (url instanceof URL) return url.href;
if (validRedirectUrl.test(url)) return url;
return new URL(url).href;
};
var validRedirectStatuses = new Set([
301,
302,
303,
307,
308
]);
Object.defineProperty(Response$1, "redirect", {
value: function redirect(url, status = 302) {
if (!validRedirectStatuses.has(status)) throw new RangeError("Invalid status code");
return new Response$1(null, {
status,
headers: { location: parseRedirectUrl(url) }
});
},
writable: true,
configurable: true
});
Object.defineProperty(Response$1, "json", {
value: function json(data, init) {
const body = JSON.stringify(data);
if (body === void 0) throw new TypeError("The data is not JSON serializable");
const initHeaders = init?.headers;
let headers;
if (initHeaders) {
headers = new Headers(initHeaders);
if (!headers.has("content-type")) headers.set("content-type", "application/json");
} else headers = { "content-type": "application/json" };
return new Response$1(body, {
status: init?.status ?? 200,
statusText: init?.statusText,
headers
});
},
writable: true,
configurable: true
});
async function readWithoutBlocking(readPromise) {
return Promise.race([readPromise, Promise.resolve().then(() => Promise.resolve(void 0))]);
}
function writeFromReadableStreamDefaultReader(reader, writable, currentReadPromise) {
const cancel = (error) => {
reader.cancel(error).catch(() => {});
};
writable.on("close", cancel);
writable.on("error", cancel);
(currentReadPromise ?? reader.read()).then(flow, handleStreamError);
return reader.closed.finally(() => {
writable.off("close", cancel);
writable.off("error", cancel);
});
function handleStreamError(error) {
if (error) writable.destroy(error);
}
function onDrain() {
reader.read().then(flow, handleStreamError);
}
function flow({ done, value }) {
try {
if (done) writable.end();
else if (!writable.write(value)) writable.once("drain", onDrain);
else return reader.read().then(flow, handleStreamError);
} catch (e) {
handleStreamError(e);
}
}
}
function writeFromReadableStream(stream, writable) {
if (stream.locked) throw new TypeError("ReadableStream is locked.");
else if (writable.destroyed) return;
return writeFromReadableStreamDefaultReader(stream.getReader(), writable);
}
var buildOutgoingHttpHeaders = (headers, defaultContentType) => {
const res = {};
if (!(headers instanceof Headers)) headers = new Headers(headers ?? void 0);
if (headers.has("set-cookie")) {
const cookies = [];
for (const [k, v] of headers) if (k === "set-cookie") cookies.push(v);
else res[k] = v;
if (cookies.length > 0) res["set-cookie"] = cookies;
} else for (const [k, v] of headers) res[k] = v;
if (defaultContentType) res["content-type"] ??= defaultContentType;
return res;
};
var outgoingEnded = Symbol("outgoingEnded");
var incomingDraining = Symbol("incomingDraining");
var DRAIN_TIMEOUT_MS = 500;
var MAX_DRAIN_BYTES = 64 * 1024 * 1024;
var drainIncoming = (incoming) => {
const incomingWithDrainState = incoming;
if (incoming.destroyed || incomingWithDrainState[incomingDraining]) return;
incomingWithDrainState[incomingDraining] = true;
if (incoming instanceof node_http2.Http2ServerRequest) {
try {
incoming.stream?.close?.(node_http2.constants.NGHTTP2_NO_ERROR);
} catch {}
return;
}
let bytesRead = 0;
const cleanup = () => {
clearTimeout(timer);
incoming.off("data", onData);
incoming.off("end", cleanup);
incoming.off("error", cleanup);
};
const forceClose = () => {
cleanup();
const socket = incoming.socket;
if (socket && !socket.destroyed) socket.destroySoon();
};
const timer = setTimeout(forceClose, DRAIN_TIMEOUT_MS);
timer.unref?.();
const onData = (chunk) => {
bytesRead += chunk.length;
if (bytesRead > MAX_DRAIN_BYTES) forceClose();
};
incoming.on("data", onData);
incoming.on("end", cleanup);
incoming.on("error", cleanup);
incoming.resume();
};
var makeCloseHandler = (req, incoming, outgoing, needsBodyCleanup) => () => {
if (incoming.errored) req[abortRequest](incoming.errored.toString());
else if (!outgoing.writableFinished) req[abortRequest]("Client connection prematurely closed.");
if (needsBodyCleanup && !incoming.readableEnded) setTimeout(() => {
if (!incoming.readableEnded) setTimeout(() => {
drainIncoming(incoming);
});
});
};
var isImmediateCacheableResponse = (res) => {
if (!(cacheKey in res)) return false;
const body = res[cacheKey][1];
return body === null || typeof body === "string" || body instanceof Uint8Array;
};
var handleRequestError = () => new Response(null, { status: 400 });
var handleFetchError = (e) => new Response(null, { status: e instanceof Error && (e.name === "TimeoutError" || e.constructor.name === "TimeoutError") ? 504 : 500 });
var handleResponseError = (e, outgoing) => {
const err = e instanceof Error ? e : new Error("unknown error", { cause: e });
if (err.code === "ERR_STREAM_PREMATURE_CLOSE") console.info("The user aborted a request.");
else {
console.error(e);
if (!outgoing.headersSent) outgoing.writeHead(500, { "Content-Type": "text/plain" });
outgoing.end(`Error: ${err.message}`);
outgoing.destroy(err);
}
};
var flushHeaders = (outgoing) => {
if ("flushHeaders" in outgoing && outgoing.writable) outgoing.flushHeaders();
};
var responseViaCache = async (res, outgoing) => {
let [status, body, header] = res[cacheKey];
if (!header) {
if (body === null) {
outgoing.writeHead(status);
outgoing.end();
} else if (typeof body === "string") {
outgoing.writeHead(status, {
"Content-Type": defaultContentType,
"Content-Length": Buffer.byteLength(body)
});
outgoing.end(body);
} else if (body instanceof Uint8Array) {
outgoing.writeHead(status, {
"Content-Type": defaultContentType,
"Content-Length": body.byteLength
});
outgoing.end(body);
} else if (body instanceof Blob) {
outgoing.writeHead(status, {
"Content-Type": defaultContentType,
"Content-Length": body.size
});
outgoing.end(new Uint8Array(await body.arrayBuffer()));
} else {
outgoing.writeHead(status, { "Content-Type": defaultContentType });
flushHeaders(outgoing);
await writeFromReadableStream(body, outgoing)?.catch((e) => handleResponseError(e, outgoing));
}
outgoing[outgoingEnded]?.();
return;
}
let hasContentLength = false;
if (header instanceof Headers) {
hasContentLength = header.has("content-length");
header = buildOutgoingHttpHeaders(header, body === null ? void 0 : defaultContentType);
} else if (Array.isArray(header)) {
const headerObj = new Headers(header);
hasContentLength = headerObj.has("content-length");
header = buildOutgoingHttpHeaders(headerObj, body === null ? void 0 : defaultContentType);
} else for (const key in header) if (key.length === 14 && key.toLowerCase() === "content-length") {
hasContentLength = true;
break;
}
if (!hasContentLength) {
if (typeof body === "string") header["Content-Length"] = Buffer.byteLength(body);
else if (body instanceof Uint8Array) header["Content-Length"] = body.byteLength;
else if (body instanceof Blob) header["Content-Length"] = body.size;
}
outgoing.writeHead(status, header);
if (body == null) outgoing.end();
else if (typeof body === "string" || body instanceof Uint8Array) outgoing.end(body);
else if (body instanceof Blob) outgoing.end(new Uint8Array(await body.arrayBuffer()));
else {
flushHeaders(outgoing);
await writeFromReadableStream(body, outgoing)?.catch((e) => handleResponseError(e, outgoing));
}
outgoing[outgoingEnded]?.();
};
var isPromise = (res) => typeof res.then === "function";
var responseViaResponseObject = async (res, outgoing, options = {}) => {
if (isPromise(res)) if (options.errorHandler) try {
res = await res;
} catch (err) {
const errRes = await options.errorHandler(err);
if (!errRes) return;
res = errRes;
}
else res = await res.catch(handleFetchError);
if (cacheKey in res) return responseViaCache(res, outgoing);
const resHeaderRecord = buildOutgoingHttpHeaders(res.headers, res.body === null ? void 0 : defaultContentType);
if (res.body) {
const reader = res.body.getReader();
const values = [];
let done = false;
let currentReadPromise = void 0;
if (resHeaderRecord["transfer-encoding"] !== "chunked") {
let maxReadCount = 2;
for (let i = 0; i < maxReadCount; i++) {
currentReadPromise ||= reader.read();
const chunk = await readWithoutBlocking(currentReadPromise).catch((e) => {
console.error(e);
done = true;
});
if (!chunk) {
if (i === 1) {
await new Promise((resolve) => setTimeout(resolve));
maxReadCount = 3;
continue;
}
break;
}
currentReadPromise = void 0;
if (chunk.value) values.push(chunk.value);
if (chunk.done) {
done = true;
break;
}
}
if (done && !("content-length" in resHeaderRecord)) resHeaderRecord["content-length"] = values.reduce((acc, value) => acc + value.length, 0);
}
outgoing.writeHead(res.status, resHeaderRecord);
values.forEach((value) => {
outgoing.write(value);
});
if (done) outgoing.end();
else {
if (values.length === 0) flushHeaders(outgoing);
await writeFromReadableStreamDefaultReader(reader, outgoing, currentReadPromise);
}
} else if (resHeaderRecord[require_constants.X_ALREADY_SENT]) {} else {
outgoing.writeHead(res.status, resHeaderRecord);
outgoing.end();
}
outgoing[outgoingEnded]?.();
};
var getRequestListener = (fetchCallback, options = {}) => {
const autoCleanupIncoming = options.autoCleanupIncoming ?? true;
if (options.overrideGlobalObjects !== false && global.Request !== Request$1) {
Object.defineProperty(global, "Request", { value: Request$1 });
Object.defineProperty(global, "Response", { value: Response$1 });
}
return async (incoming, outgoing) => {
let res, req;
let needsBodyCleanup = false;
let closeHandlerAttached = false;
const ensureCloseHandler = () => {
if (!req || closeHandlerAttached) return;
closeHandlerAttached = true;
outgoing.on("close", makeCloseHandler(req, incoming, outgoing, needsBodyCleanup));
};
try {
req = newRequest(incoming, options.hostname);
needsBodyCleanup = autoCleanupIncoming && !(incoming.method === "GET" || incoming.method === "HEAD");
if (needsBodyCleanup) {
incoming[wrapBodyStream] = true;
if (incoming instanceof node_http2.Http2ServerRequest) outgoing[outgoingEnded] = () => {
if (!incoming.readableEnded) setTimeout(() => {
if (!incoming.readableEnded) setTimeout(() => {
incoming.destroy();
outgoing.destroy();
});
});
};
}
res = fetchCallback(req, {
incoming,
outgoing
});
if (!isPromise(res) && isImmediateCacheableResponse(res)) {
if (needsBodyCleanup && !incoming.readableEnded) outgoing.once("finish", () => {
if (!incoming.readableEnded) drainIncoming(incoming);
});
return responseViaCache(res, outgoing);
}
ensureCloseHandler();
} catch (e) {
if (!res) if (options.errorHandler) {
ensureCloseHandler();
res = await options.errorHandler(req ? e : toRequestError(e));
if (!res) return;
} else if (!req) res = handleRequestError();
else res = handleFetchError(e);
else return handleResponseError(e, outgoing);
}
try {
return await responseViaResponseObject(res, outgoing, options);
} catch (e) {
return handleResponseError(e, outgoing);
}
};
};
/**
* @link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
*/
var CloseEvent = globalThis.CloseEvent ?? class extends Event {
#eventInitDict;
constructor(type, eventInitDict = {}) {
super(type, eventInitDict);
this.#eventInitDict = eventInitDict;
}
get wasClean() {
return this.#eventInitDict.wasClean ?? false;
}
get code() {
return this.#eventInitDict.code ?? 0;
}
get reason() {
return this.#eventInitDict.reason ?? "";
}
};
var generateConnectionSymbol = () => Symbol("connection");
var CONNECTION_SYMBOL_KEY = Symbol("CONNECTION_SYMBOL_KEY");
var WAIT_FOR_WEBSOCKET_SYMBOL = Symbol("WAIT_FOR_WEBSOCKET_SYMBOL");
var responseHeadersToSkip = new Set([
"connection",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
"sec-websocket-accept",
"sec-websocket-extensions",
"sec-websocket-protocol"
]);
var appendResponseHeaders = (headers, responseHeaders) => {
if (!responseHeaders) return;
responseHeaders.forEach((value, key) => {
if (responseHeadersToSkip.has(key.toLowerCase())) return;
headers.push(`${key}: ${value}`);
});
};
var rejectUpgradeRequest = (socket, status, responseHeaders) => {
const responseLines = ["Connection: close", "Content-Length: 0"];
appendResponseHeaders(responseLines, responseHeaders);
socket.end(`HTTP/1.1 ${status.toString()} ${node_http.STATUS_CODES[status] ?? ""}\r\n${responseLines.join("\r\n")}\r\n\r
`);
};
var createUpgradeRequest = (request) => {
const protocol = request.socket.encrypted ? "https" : "http";
const url = new URL(request.url ?? "/", `${protocol}://${request.headers.host ?? "localhost"}`);
const headers = new Headers();
for (const key in request.headers) {
const value = request.headers[key];
if (!value) continue;
headers.append(key, Array.isArray(value) ? value[0] : value);
}
return new Request(url, { headers });
};
var setupWebSocket = (options) => {
const { server, fetchCallback, wss } = options;
const waiterMap = /* @__PURE__ */ new Map();
wss.on("connection", (ws, request) => {
const waiter = waiterMap.get(request);
if (waiter) {
waiter.resolve(ws);
waiterMap.delete(request);
}
});
const waitForWebSocket = (request, connectionSymbol) => {
return new Promise((resolve) => {
waiterMap.set(request, {
resolve,
connectionSymbol
});
});
};
server.on("upgrade", async (request, socket, head) => {
if (request.headers.upgrade?.toLowerCase() !== "websocket") return;
const env = {
incoming: request,
outgoing: void 0,
wss,
[WAIT_FOR_WEBSOCKET_SYMBOL]: waitForWebSocket
};
let status = 400;
let responseHeaders;
try {
const response = await fetchCallback(createUpgradeRequest(request), env);
if (response instanceof Response) {
status = response.status;
responseHeaders = response.headers;
}
} catch {
if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, 500);
return;
}
const waiter = waiterMap.get(request);
if (!waiter || waiter.connectionSymbol !== env[CONNECTION_SYMBOL_KEY]) {
waiterMap.delete(request);
if (server.listenerCount("upgrade") === 1) rejectUpgradeRequest(socket, status, responseHeaders);
return;
}
const addResponseHeaders = (headers) => {
appendResponseHeaders(headers, responseHeaders);
};
wss.on("headers", addResponseHeaders);
try {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
} finally {
wss.off("headers", addResponseHeaders);
}
});
server.on("close", () => {
wss.close();
});
};
var upgradeWebSocket = (0, hono_ws.defineWebSocketHelper)(async (c, events, options) => {
if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return;
const env = c.env;
const waitForWebSocket = env[WAIT_FOR_WEBSOCKET_SYMBOL];
if (!waitForWebSocket || !env.incoming) return new Response(null, { status: 500 });
const connectionSymbol = generateConnectionSymbol();
env[CONNECTION_SYMBOL_KEY] = connectionSymbol;
(async () => {
const ws = await waitForWebSocket(env.incoming, connectionSymbol);
const messagesReceivedInStarting = [];
const bufferMessage = (data, isBinary) => {
messagesReceivedInStarting.push([data, isBinary]);
};
ws.on("message", bufferMessage);
const ctx = {
binaryType: "arraybuffer",
close(code, reason) {
ws.close(code, reason);
},
protocol: ws.protocol,
raw: ws,
get readyState() {
return ws.readyState;
},
send(source, opts) {
ws.send(source, { compress: opts?.compress });
},
url: new URL(c.req.url)
};
try {
events?.onOpen?.(new Event("open"), ctx);
} catch (e) {
(options?.onError ?? console.error)(e);
}
const handleMessage = (data, isBinary) => {
const datas = Array.isArray(data) ? data : [data];
for (const data of datas) try {
events?.onMessage?.(new MessageEvent("message", { data: isBinary ? data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) : typeof data === "string" ? data : Buffer.from(data).toString("utf-8") }), ctx);
} catch (e) {
(options?.onError ?? console.error)(e);
}
};
ws.off("message", bufferMessage);
for (const message of messagesReceivedInStarting) handleMessage(...message);
ws.on("message", (data, isBinary) => {
handleMessage(data, isBinary);
});
ws.on("close", (code, reason) => {
try {
events?.onClose?.(new CloseEvent("close", {
code,
reason: reason.toString()
}), ctx);
} catch (e) {
(options?.onError ?? console.error)(e);
}
});
ws.on("error", (error) => {
try {
events?.onError?.(new ErrorEvent("error", { error }), ctx);
} catch (e) {
(options?.onError ?? console.error)(e);
}
});
})();
return new Response();
});
var createAdaptorServer = (options) => {
const fetchCallback = options.fetch;
const requestListener = getRequestListener(fetchCallback, {
hostname: options.hostname,
overrideGlobalObjects: options.overrideGlobalObjects,
autoCleanupIncoming: options.autoCleanupIncoming
});
const server = (options.createServer || node_http.createServer)(options.serverOptions || {}, requestListener);
if (options.websocket && options.websocket.server) {
if (options.websocket.server.options.noServer !== true) throw new Error("WebSocket server must be created with { noServer: true } option");
setupWebSocket({
server,
fetchCallback,
wss: options.websocket.server
});
}
return server;
};
var serve = (options, listeningListener) => {
const server = createAdaptorServer(options);
server.listen(options?.port ?? 3e3, options.hostname, () => {
const serverInfo = server.address();
listeningListener && listeningListener(serverInfo);
});
return server;
};
exports.RequestError = RequestError;
exports.createAdaptorServer = createAdaptorServer;
exports.getRequestListener = getRequestListener;
exports.serve = serve;
exports.upgradeWebSocket = upgradeWebSocket;
})))();
var github_exports = /* @__PURE__ */ __exportAll({
channel: () => channel,
client: () => client,
commentOnIssue: () => commentOnIssue,
getPullRequestDiff: () => getPullRequestDiff
});
/**
* GitHub channel ā the webhook (server) deployment mode. Lets people summon
* Shippie by commenting `/shippie ...` on an issue or pull request. Verified
* deliveries are dispatched to the `mention` agent, which replies via Octokit.
*
* Served at `POST /channels/github/webhook` on the built server. Requires
* GITHUB_WEBHOOK_SECRET (verify inbound) and GITHUB_TOKEN (outbound comments).
* This is separate from the one-shot CI review (action.yml / `flue run review`).
*/
var MENTION = "/shippie";
var client = new Octokit({ auth: process.env.GITHUB_TOKEN });
var channel = createGitHubChannel({
webhookSecret: process.env.GITHUB_WEBHOOK_SECRET || `shippie-unconfigured-${randomUUID()}`,
async webhook({ delivery }) {
if (delivery.name === "issue_comment" && delivery.payload.action === "created") {
const { repository, issue, comment, sender } = delivery.payload;
if (sender?.type === "Bot") return void 0;
if (!comment.body?.toLowerCase().includes(MENTION)) return void 0;
const ref = {
owner: repository.owner.login,
repo: repository.name,
issueNumber: issue.number
};
await dispatch(mention_default, {
id: channel.conversationKey(ref),
input: {
type: "github.mention",
isPullRequest: Boolean(issue.pull_request),
title: issue.title,
author: sender?.login,
request: comment.body
}
});
return;
}
if (delivery.name === "pull_request_review_comment" && delivery.payload.action === "created") {
const { repository, pull_request, comment, sender } = delivery.payload;
if (sender?.type === "Bot") return void 0;
if (!comment.body?.toLowerCase().includes(MENTION)) return void 0;
const ref = {
owner: repository.owner.login,
repo: repository.name,
issueNumber: pull_request.number
};
await dispatch(mention_default, {
id: channel.conversationKey(ref),
input: {
type: "github.mention",
isPullRequest: true,
title: pull_request.title,
author: sender?.login,
request: comment.body,
path: comment.path,
line: comment.line ?? null
}
});
return;
}
}
});
/** Tool: post a reply comment on the issue/PR that summoned Shippie. */
var commentOnIssue = (ref) => defineTool({
name: "comment_on_github_issue",
description: "Post your reply as a comment on the GitHub issue or pull request that mentioned you.",
parameters: v.object({ body: v.pipe(v.string(), v.minLength(1), v.description("The markdown comment to post.")) }),
async execute({ body }) {
return `Comment posted: ${(await client.rest.issues.createComment({
owner: ref.owner,
repo: ref.repo,
issue_number: ref.issueNumber,
body
})).data.html_url}`;
}
});
/** Tool: fetch the unified diff of the pull request that summoned Shippie. */
var getPullRequestDiff = (ref) => defineTool({
name: "get_pull_request_diff",
description: "Fetch the unified diff of the pull request that mentioned you. Call this before reviewing a PR.",
parameters: v.object({}),
async execute() {
const res = await client.rest.pulls.get({
owner: ref.owner,
repo: ref.repo,
pull_number: ref.issueNumber,
mediaType: { format: "diff" }
});
return typeof res.data === "string" ? res.data : JSON.stringify(res.data);
}
});
//#endregion
//#region src/agents/mention.ts
var mention_exports = /* @__PURE__ */ __exportAll({ default: () => mention_default });
/**
* The `/shippie` mention agent (webhook/channel mode). Dispatched by the GitHub
* channel when someone comments `/shippie ...` on an issue or PR. It reads the
* request, optionally fetches the PR diff, and replies with a single comment.
*
* Runs on a deployed Flue server (not a repo checkout), so it works through the
* GitHub API tools rather than a `local()` sandbox.
*/
var mention_default = createAgent(({ id }) => {
const ref = channel.parseConversationKey(id);
return {
model: process.env.SHIPPIE_MODEL ?? "anthropic/claude-sonnet-4-6",
instructions: `You are Shippie, an automated code-review agent summoned by a "/shippie" command on ${ref.owner}/${ref.repo} #${ref.issueNumber}.
The incoming message describes the user's request. Decide what they want:
- If it is a pull request and they ask you to review it (e.g. "/shippie review"), call get_pull_request_diff, review the changed code for bugs, exposed secrets, missing tests, and risky changes, then write a concise review.
- For any other question, answer it helpfully and concisely based on the request and what you can fetch.
Rules:
- Be brief and specific. Do not restate the whole diff back to the user.
- Only raise issues you are confident about.
- ALWAYS finish by calling comment_on_github_issue exactly once with your reply (markdown).`,
tools: [commentOnIssue(ref), getPullRequestDiff(ref)]
};
});
//#endregion
//#region src/common/formatting/summary.ts
/**
* Constants for formatting comments
*/
var FORMATTING = {
SUMMARY_TITLE: "## General Summary š“āā ļø",
SEPARATOR: "\n\n---\n\n",
SIGN_OFF: "### Review powered by [Shippie š¢](https://github.com/mattzcarey/shippie)",
CTA: `<details>
<summary>š Good review?</summary>
---
**Help us improve!** Your feedback and support make Shippie better for everyone.
ā **Quick win?** [Star the repo](https://github.com/mattzcarey/shippie) if you find it useful
š” **Have ideas?** [Open a discussion](https://github.com/mattzcarey/shippie/discussions)
š ļø **Wanna chat about agents?** [Send me a DM](https://x.com/mattzcarey)
---
*Sponsor the project* to preview features and influence the roadmap
š [YOUR COMPANY HERE](https://sustain.dev/sponsor/shippie) š
</details>`,
TOOL_CALLS_TITLE: "š ļø Tool Calls",
TOKEN_USAGE_TITLE: "š Token Usage"
};
/**
* Formats a thread comment with title, content, and sign-off
*/
var formatSummary = (comment) => {
return `${FORMATTING.SUMMARY_TITLE}\n\n${comment}${FORMATTING.SEPARATOR}${FORMATTING.SIGN_OFF}\n\n${FORMATTING.CTA}`;
};
//#endregion
//#region src/github/reporter.ts
/** Make a workspace-absolute path relative to the repo root for the GitHub API. */
var toRepoPath = (workspace, filePath) => isAbsolute(filePath) ? relative(workspace, filePath) : filePath;
var createGithubReporter = (cfg) => {
const target = cfg.github;
if (!target) throw new Error("GitHub reporter requires owner/repo/prNumber. Is this running on a PR?");
const octokit = new Octokit({ auth: target.token });
const { owner, repo, prNumber } = target;
const resolveCommitId = async () => {
if (cfg.headSha) return cfg.headSha;
return (await octokit.rest.pulls.get({
owner,
repo,
pull_number: prNumber
})).data.head.sha;
};
return {
postReviewComment: async ({ filePath, comment, startLine, endLine }) => {
const path = toRepoPath(cfg.workspace, filePath);
const commit_id = await resolveCommitId();
const line = endLine ?? startLine;
try {
const multiLine = startLine && endLine && startLine !== endLine;
const { data } = await octokit.rest.pulls.createReviewComment({
owner,
repo,
pull_number: prNumber,
commit_id,
body: comment,
path,
line,
...multiLine ? {
start_line: startLine,
start_side: "RIGHT",
side: "RIGHT"
} : {}
});
return data.html_url;
} catch (error) {
throw new Error(`Failed to post review comment on ${path}: ${error instanceof Error ? error.message : String(error)}`);
}
},
postSummary: async (comment) => {
const body = formatSummary(comment);
const { data: existing } = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: prNumber
});
const prior = existing.find((c) => c.body?.includes(FORMATTING.SIGN_OFF));
if (prior) {
const { data } = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: prior.id,
body
});
return data.html_url;
}
const { data } = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body
});
return data.html_url;
}
};
};
var LOCAL_RUN_TIMESTAMP = (/* @__PURE__ */ new Date()).toISOString().replace(/:/g, "-");
var createLocalReporter = (cfg) => {
const reviewDir = join(cfg.workspace, ".shippie", "review");
const reviewFile = join(reviewDir, `local_${LOCAL_RUN_TIMESTAMP}.md`);
const ensureDir = async () => {
await mkdir(reviewDir, { recursive: true });
await writeFile(join(reviewDir, ".gitignore"), "*").catch(() => {});
};
return {
postReviewComment: async ({ filePath, comment, startLine, endLine }) => {
await ensureDir();
await appendFile(reviewFile, `### ${toRepoPath(cfg.workspace, filePath)}${startLine ? `:${startLine}${endLine && endLine !== startLine ? `-${endLine}` : ""}` : ""}\n\n${comment}\n\n`);
return `Comment written to ${reviewFile}`;
},
postSummary: async (comment) => {
await ensureDir();
await appendFile(reviewFile, `${formatSummary(comment)}\n`);
return `Summary written to ${reviewFile}`;
}
};
};
var createReporter = (cfg) => {
if (cfg.platform === "github" && !cfg.github) {
console.error("[shippie] platform is \"github\" but no PR context was found (owner/repo/prNumber). Falling back to local file output. Set GITHUB_TOKEN + PR metadata to post on the PR.");
return createLocalReporter(cfg);
}
return cfg.platform === "github" ? createGithubReporter(cfg) : createLocalReporter(cfg);
};
//#endregion
//#region src/review/config.ts
var DEFAULT_MODEL = "anthropic/claude-sonnet-4-6";
var DEFAULT_THINKING = "medium";
var parseMcpServers = (payload, env) => {
if (payload.mcpServers && Object.keys(payload.mcpServers).length