Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
351 lines
10 KiB
JavaScript
351 lines
10 KiB
JavaScript
"use strict";
|
|
|
|
const path = require("node:path");
|
|
const { spawn } = require("node:child_process");
|
|
const { createHash } = require("node:crypto");
|
|
const { StringDecoder } = require("node:string_decoder");
|
|
|
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
const INITIALIZE_TIMEOUT_MS = 10_000;
|
|
const MAX_STDERR_CHARS = 32_000;
|
|
const MAX_JSONL_LINE_BYTES = 16 * 1024 * 1024;
|
|
const CLOSE_KILL_GRACE_MS = 750;
|
|
|
|
function createBoundedLineReader(stream, onLine, onError, maxLineBytes) {
|
|
const decoder = new StringDecoder("utf8");
|
|
let buffer = "";
|
|
let bufferedBytes = 0;
|
|
let closed = false;
|
|
|
|
const fail = () => {
|
|
buffer = "";
|
|
bufferedBytes = 0;
|
|
onError(new Error(`Codex App Server message exceeded ${maxLineBytes} bytes`));
|
|
};
|
|
const onData = (chunk) => {
|
|
if (closed) return;
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
|
bufferedBytes += bytes.length;
|
|
buffer += decoder.write(bytes);
|
|
let index;
|
|
let consumedLine = false;
|
|
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
const line = buffer.slice(0, index).trim();
|
|
buffer = buffer.slice(index + 1);
|
|
consumedLine = true;
|
|
if (line) onLine(line);
|
|
if (closed) return;
|
|
}
|
|
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
|
if (bufferedBytes > maxLineBytes) fail();
|
|
};
|
|
const onEnd = () => {
|
|
if (closed) return;
|
|
buffer += decoder.end();
|
|
const line = buffer.trim();
|
|
buffer = "";
|
|
bufferedBytes = 0;
|
|
if (line) onLine(line);
|
|
};
|
|
|
|
stream?.on?.("data", onData);
|
|
stream?.once?.("end", onEnd);
|
|
return {
|
|
close() {
|
|
if (closed) return;
|
|
closed = true;
|
|
buffer = "";
|
|
bufferedBytes = 0;
|
|
stream?.removeListener?.("data", onData);
|
|
stream?.removeListener?.("end", onEnd);
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildCodexAppServerLaunch(binPath, args = ["app-server", "--stdio"], {
|
|
nodePath = process.execPath,
|
|
} = {}) {
|
|
const executable = String(binPath || "").trim();
|
|
if (!executable) {
|
|
throw new Error("Codex binary not found. Configure Codex in Settings -> AI.");
|
|
}
|
|
const extension = path.extname(executable).toLowerCase();
|
|
if (extension === ".js" || extension === ".cjs" || extension === ".mjs") {
|
|
return {
|
|
command: nodePath,
|
|
args: [executable, ...args],
|
|
env: { ELECTRON_RUN_AS_NODE: "1" },
|
|
};
|
|
}
|
|
if (extension === ".cmd" || extension === ".bat" || extension === ".ps1") {
|
|
throw new Error(
|
|
`Codex App Server cannot launch the shell shim ${executable}. ` +
|
|
"Configure the native Codex executable or reinstall the Codex CLI.",
|
|
);
|
|
}
|
|
return { command: executable, args };
|
|
}
|
|
|
|
function buildCodexAppServerKey(binPath, env) {
|
|
const fingerprint = createHash("sha256")
|
|
.update(JSON.stringify(
|
|
Object.entries(env || {})
|
|
.map(([key, value]) => [key, String(value)])
|
|
.sort(([left], [right]) => left.localeCompare(right)),
|
|
))
|
|
.digest("hex");
|
|
return `${String(binPath || "")}\u0000${fingerprint}`;
|
|
}
|
|
|
|
class CodexAppServerConnection {
|
|
constructor({
|
|
binPath,
|
|
env,
|
|
appVersion = "0.0.0",
|
|
spawnImpl = spawn,
|
|
onNotification,
|
|
onServerRequest,
|
|
onFatal,
|
|
closeKillGraceMs = CLOSE_KILL_GRACE_MS,
|
|
maxJsonlLineBytes = MAX_JSONL_LINE_BYTES,
|
|
}) {
|
|
this.binPath = binPath;
|
|
this.env = env || {};
|
|
this.appVersion = appVersion;
|
|
this.spawnImpl = spawnImpl;
|
|
this.onNotification = onNotification;
|
|
this.onServerRequest = onServerRequest;
|
|
this.onFatal = onFatal;
|
|
this.closeKillGraceMs = closeKillGraceMs;
|
|
this.maxJsonlLineBytes = maxJsonlLineBytes;
|
|
this.process = null;
|
|
this.closingProcesses = new Map();
|
|
this.readline = null;
|
|
this.nextRequestId = 1;
|
|
this.pending = new Map();
|
|
this.startPromise = null;
|
|
this.initialized = false;
|
|
this.closing = false;
|
|
this.stderr = "";
|
|
}
|
|
|
|
async start() {
|
|
if (this.initialized && this.process && !this.process.killed) return this;
|
|
if (this.startPromise) return this.startPromise;
|
|
this.startPromise = this.#startInternal().finally(() => {
|
|
this.startPromise = null;
|
|
});
|
|
return this.startPromise;
|
|
}
|
|
|
|
async #startInternal() {
|
|
this.closing = false;
|
|
this.stderr = "";
|
|
const launch = buildCodexAppServerLaunch(this.binPath);
|
|
const child = this.spawnImpl(launch.command, launch.args, {
|
|
cwd: process.cwd(),
|
|
env: { ...this.env, ...(launch.env || {}) },
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
shell: false,
|
|
});
|
|
this.process = child;
|
|
|
|
child.stderr?.setEncoding?.("utf8");
|
|
child.stderr?.on?.("data", (chunk) => {
|
|
this.stderr = `${this.stderr}${String(chunk || "")}`.slice(-MAX_STDERR_CHARS);
|
|
});
|
|
|
|
this.readline = createBoundedLineReader(
|
|
child.stdout,
|
|
(line) => this.#handleLine(line),
|
|
(error) => this.#handleFatal(error),
|
|
this.maxJsonlLineBytes,
|
|
);
|
|
|
|
child.once("error", (error) => {
|
|
if (this.closingProcesses.has(child) || this.process !== child) return;
|
|
this.#handleFatal(error);
|
|
});
|
|
child.once("exit", (code, signal) => {
|
|
const wasClosing = this.closingProcesses.has(child);
|
|
if (wasClosing) this.#releaseClosingProcess(child);
|
|
if (wasClosing || this.process !== child) return;
|
|
const detail = this.stderr.trim();
|
|
const suffix = detail ? `\n${detail}` : "";
|
|
this.#handleFatal(new Error(
|
|
`Codex App Server exited unexpectedly (code ${code ?? "null"}, signal ${signal ?? "none"}).${suffix}`,
|
|
));
|
|
});
|
|
|
|
try {
|
|
await this.request("initialize", {
|
|
clientInfo: {
|
|
name: "netcatty",
|
|
title: "Netcatty",
|
|
version: this.appVersion,
|
|
},
|
|
capabilities: {
|
|
experimentalApi: true,
|
|
requestAttestation: false,
|
|
mcpServerOpenaiFormElicitation: false,
|
|
},
|
|
}, INITIALIZE_TIMEOUT_MS, { skipStart: true });
|
|
this.notify("initialized", {});
|
|
this.initialized = true;
|
|
return this;
|
|
} catch (error) {
|
|
this.close();
|
|
const detail = this.stderr.trim();
|
|
if (detail && !String(error?.message || error).includes(detail)) {
|
|
throw new Error(`${error?.message || error}\n${detail}`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async request(method, params = {}, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, options = {}) {
|
|
if (!options.skipStart) await this.start();
|
|
const id = this.nextRequestId++;
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`Codex App Server request timed out: ${method}`));
|
|
}, timeoutMs);
|
|
this.pending.set(id, { method, resolve, reject, timer });
|
|
try {
|
|
this.#write({ id, method, params });
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
this.pending.delete(id);
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
notify(method, params = {}) {
|
|
this.#write({ method, params });
|
|
}
|
|
|
|
respond(id, result) {
|
|
this.#write({ id, result });
|
|
}
|
|
|
|
respondError(id, code, message, data) {
|
|
const error = { code, message };
|
|
if (data !== undefined) error.data = data;
|
|
this.#write({ id, error });
|
|
}
|
|
|
|
#write(message) {
|
|
const stdin = this.process?.stdin;
|
|
if (!stdin || stdin.destroyed || !stdin.writable) {
|
|
throw new Error("Codex App Server stdin is unavailable");
|
|
}
|
|
stdin.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
#handleLine(rawLine) {
|
|
const line = String(rawLine || "").trim();
|
|
if (!line) return;
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
this.#handleFatal(new Error(`Codex App Server emitted invalid JSON: ${line.slice(0, 500)}`));
|
|
return;
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(message, "id") && !message.method) {
|
|
const entry = this.pending.get(message.id);
|
|
if (!entry) return;
|
|
this.pending.delete(message.id);
|
|
clearTimeout(entry.timer);
|
|
if (message.error) {
|
|
const error = new Error(message.error.message || `Codex App Server ${entry.method} failed`);
|
|
error.code = message.error.code;
|
|
error.data = message.error.data;
|
|
entry.reject(error);
|
|
} else {
|
|
entry.resolve(message.result);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.method && Object.prototype.hasOwnProperty.call(message, "id")) {
|
|
Promise.resolve(this.onServerRequest?.(message, this)).catch((error) => {
|
|
try {
|
|
this.respondError(message.id, -32603, error?.message || String(error));
|
|
} catch {}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.method) {
|
|
try {
|
|
this.onNotification?.(message, this);
|
|
} catch (error) {
|
|
this.#handleFatal(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
#handleFatal(error) {
|
|
if (this.closing) return;
|
|
this.initialized = false;
|
|
const fatal = error instanceof Error ? error : new Error(String(error));
|
|
for (const [, entry] of this.pending) {
|
|
clearTimeout(entry.timer);
|
|
entry.reject(fatal);
|
|
}
|
|
this.pending.clear();
|
|
try { this.onFatal?.(fatal, this); } catch {}
|
|
this.close();
|
|
}
|
|
|
|
#releaseClosingProcess(child) {
|
|
if (!this.closingProcesses.has(child)) return;
|
|
clearTimeout(this.closingProcesses.get(child));
|
|
this.closingProcesses.delete(child);
|
|
}
|
|
|
|
getClosingProcessCountForTests() {
|
|
return this.closingProcesses.size;
|
|
}
|
|
|
|
close() {
|
|
this.closing = true;
|
|
this.initialized = false;
|
|
try { this.readline?.close?.(); } catch {}
|
|
this.readline = null;
|
|
for (const [, entry] of this.pending) {
|
|
clearTimeout(entry.timer);
|
|
entry.reject(new Error("Codex App Server connection closed"));
|
|
}
|
|
this.pending.clear();
|
|
const child = this.process;
|
|
this.process = null;
|
|
if (!child) return;
|
|
try { child.stdin?.end?.(); } catch {}
|
|
this.closingProcesses.set(child, null);
|
|
const killTimer = setTimeout(() => {
|
|
if (!this.closingProcesses.has(child)) return;
|
|
try { child.kill?.("SIGKILL"); } catch {}
|
|
this.#releaseClosingProcess(child);
|
|
}, this.closeKillGraceMs);
|
|
killTimer.unref?.();
|
|
this.closingProcesses.set(child, killTimer);
|
|
try { child.kill?.("SIGTERM"); } catch {}
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
CodexAppServerConnection,
|
|
buildCodexAppServerKey,
|
|
buildCodexAppServerLaunch,
|
|
DEFAULT_REQUEST_TIMEOUT_MS,
|
|
INITIALIZE_TIMEOUT_MS,
|
|
CLOSE_KILL_GRACE_MS,
|
|
MAX_JSONL_LINE_BYTES,
|
|
};
|