* fix: add shell and windowsHide options for Windows spawn in app-server
On Windows, spawn("codex", ["app-server"]) fails with ENOENT because
Node.js cannot resolve .cmd shims without shell: true. This adds
platform-gated shell and windowsHide options to the app-server spawn
call, and uses terminateProcessTree for proper process tree cleanup
since shell: true wraps the child in cmd.exe.
Without terminateProcessTree, plain SIGTERM only kills cmd.exe and
leaves the actual codex node process orphaned — verified with 274+
zombie node.exe processes accumulating on Windows.
Fixes #32
Fixes #46
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: guard terminateProcessTree against PID reuse after process exit
ChildProcess.killed only reflects whether .kill() was called by this
process — it stays false when the child exits on its own. On Windows,
where PIDs are recycled quickly, the 50 ms timer could fire after
cmd.exe has exited and its PID has been reassigned, causing taskkill
to terminate an unrelated process.
Add an exitCode === null check so the tree-kill path is skipped once
the child has already exited.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
348 lines
9.7 KiB
JavaScript
348 lines
9.7 KiB
JavaScript
/**
|
|
* @typedef {Error & { data?: unknown, rpcCode?: number }} ProtocolError
|
|
* @typedef {import("./app-server-protocol").AppServerMethod} AppServerMethod
|
|
* @typedef {import("./app-server-protocol").AppServerNotification} AppServerNotification
|
|
* @typedef {import("./app-server-protocol").AppServerNotificationHandler} AppServerNotificationHandler
|
|
* @typedef {import("./app-server-protocol").ClientInfo} ClientInfo
|
|
* @typedef {import("./app-server-protocol").CodexAppServerClientOptions} CodexAppServerClientOptions
|
|
* @typedef {import("./app-server-protocol").InitializeCapabilities} InitializeCapabilities
|
|
*/
|
|
import fs from "node:fs";
|
|
import net from "node:net";
|
|
import process from "node:process";
|
|
import { spawn } from "node:child_process";
|
|
import readline from "node:readline";
|
|
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
|
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
|
|
import { terminateProcessTree } from "./process.mjs";
|
|
|
|
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
|
|
const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8"));
|
|
|
|
export const BROKER_ENDPOINT_ENV = "CODEX_COMPANION_APP_SERVER_ENDPOINT";
|
|
export const BROKER_BUSY_RPC_CODE = -32001;
|
|
|
|
/** @type {ClientInfo} */
|
|
const DEFAULT_CLIENT_INFO = {
|
|
title: "Codex Plugin",
|
|
name: "Claude Code",
|
|
version: PLUGIN_MANIFEST.version ?? "0.0.0"
|
|
};
|
|
|
|
/** @type {InitializeCapabilities} */
|
|
const DEFAULT_CAPABILITIES = {
|
|
experimentalApi: false,
|
|
optOutNotificationMethods: [
|
|
"item/agentMessage/delta",
|
|
"item/reasoning/summaryTextDelta",
|
|
"item/reasoning/summaryPartAdded",
|
|
"item/reasoning/textDelta"
|
|
]
|
|
};
|
|
|
|
function buildJsonRpcError(code, message, data) {
|
|
return data === undefined ? { code, message } : { code, message, data };
|
|
}
|
|
|
|
function createProtocolError(message, data) {
|
|
const error = /** @type {ProtocolError} */ (new Error(message));
|
|
error.data = data;
|
|
if (data?.code !== undefined) {
|
|
error.rpcCode = data.code;
|
|
}
|
|
return error;
|
|
}
|
|
|
|
class AppServerClientBase {
|
|
constructor(cwd, options = {}) {
|
|
this.cwd = cwd;
|
|
this.options = options;
|
|
this.pending = new Map();
|
|
this.nextId = 1;
|
|
this.stderr = "";
|
|
this.closed = false;
|
|
this.exitError = null;
|
|
/** @type {AppServerNotificationHandler | null} */
|
|
this.notificationHandler = null;
|
|
this.lineBuffer = "";
|
|
this.transport = "unknown";
|
|
|
|
this.exitPromise = new Promise((resolve) => {
|
|
this.resolveExit = resolve;
|
|
});
|
|
}
|
|
|
|
setNotificationHandler(handler) {
|
|
this.notificationHandler = handler;
|
|
}
|
|
|
|
/**
|
|
* @template {AppServerMethod} M
|
|
* @param {M} method
|
|
* @param {import("./app-server-protocol").AppServerRequestParams<M>} params
|
|
* @returns {Promise<import("./app-server-protocol").AppServerResponse<M>>}
|
|
*/
|
|
request(method, params) {
|
|
if (this.closed) {
|
|
throw new Error("codex app-server client is closed.");
|
|
}
|
|
|
|
const id = this.nextId;
|
|
this.nextId += 1;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
this.pending.set(id, { resolve, reject, method });
|
|
this.sendMessage({ id, method, params });
|
|
});
|
|
}
|
|
|
|
notify(method, params = {}) {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
this.sendMessage({ method, params });
|
|
}
|
|
|
|
handleChunk(chunk) {
|
|
this.lineBuffer += chunk;
|
|
let newlineIndex = this.lineBuffer.indexOf("\n");
|
|
while (newlineIndex !== -1) {
|
|
const line = this.lineBuffer.slice(0, newlineIndex);
|
|
this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
|
|
this.handleLine(line);
|
|
newlineIndex = this.lineBuffer.indexOf("\n");
|
|
}
|
|
}
|
|
|
|
handleLine(line) {
|
|
if (!line.trim()) {
|
|
return;
|
|
}
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch (error) {
|
|
this.handleExit(createProtocolError(`Failed to parse codex app-server JSONL: ${error.message}`, { line }));
|
|
return;
|
|
}
|
|
|
|
if (message.id !== undefined && message.method) {
|
|
this.handleServerRequest(message);
|
|
return;
|
|
}
|
|
|
|
if (message.id !== undefined) {
|
|
const pending = this.pending.get(message.id);
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
this.pending.delete(message.id);
|
|
|
|
if (message.error) {
|
|
pending.reject(createProtocolError(message.error.message ?? `codex app-server ${pending.method} failed.`, message.error));
|
|
} else {
|
|
pending.resolve(message.result ?? {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.method && this.notificationHandler) {
|
|
this.notificationHandler(/** @type {AppServerNotification} */ (message));
|
|
}
|
|
}
|
|
|
|
handleServerRequest(message) {
|
|
this.sendMessage({
|
|
id: message.id,
|
|
error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`)
|
|
});
|
|
}
|
|
|
|
handleExit(error) {
|
|
if (this.exitResolved) {
|
|
return;
|
|
}
|
|
|
|
this.exitResolved = true;
|
|
this.exitError = error ?? null;
|
|
|
|
for (const pending of this.pending.values()) {
|
|
pending.reject(this.exitError ?? new Error("codex app-server connection closed."));
|
|
}
|
|
this.pending.clear();
|
|
this.resolveExit(undefined);
|
|
}
|
|
|
|
sendMessage(_message) {
|
|
throw new Error("sendMessage must be implemented by subclasses.");
|
|
}
|
|
}
|
|
|
|
class SpawnedCodexAppServerClient extends AppServerClientBase {
|
|
constructor(cwd, options = {}) {
|
|
super(cwd, options);
|
|
this.transport = "direct";
|
|
}
|
|
|
|
async initialize() {
|
|
this.proc = spawn("codex", ["app-server"], {
|
|
cwd: this.cwd,
|
|
env: this.options.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
shell: process.platform === "win32",
|
|
windowsHide: true
|
|
});
|
|
|
|
this.proc.stdout.setEncoding("utf8");
|
|
this.proc.stderr.setEncoding("utf8");
|
|
|
|
this.proc.stderr.on("data", (chunk) => {
|
|
this.stderr += chunk;
|
|
});
|
|
|
|
this.proc.on("error", (error) => {
|
|
this.handleExit(error);
|
|
});
|
|
|
|
this.proc.on("exit", (code, signal) => {
|
|
const detail =
|
|
code === 0
|
|
? null
|
|
: createProtocolError(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
|
|
this.handleExit(detail);
|
|
});
|
|
|
|
this.readline = readline.createInterface({ input: this.proc.stdout });
|
|
this.readline.on("line", (line) => {
|
|
this.handleLine(line);
|
|
});
|
|
|
|
await this.request("initialize", {
|
|
clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES
|
|
});
|
|
this.notify("initialized", {});
|
|
}
|
|
|
|
async close() {
|
|
if (this.closed) {
|
|
await this.exitPromise;
|
|
return;
|
|
}
|
|
|
|
this.closed = true;
|
|
|
|
if (this.readline) {
|
|
this.readline.close();
|
|
}
|
|
|
|
if (this.proc && !this.proc.killed) {
|
|
this.proc.stdin.end();
|
|
setTimeout(() => {
|
|
if (this.proc && !this.proc.killed && this.proc.exitCode === null) {
|
|
// On Windows with shell: true, the direct child is cmd.exe.
|
|
// Use terminateProcessTree to kill the entire tree including
|
|
// the grandchild node process.
|
|
if (process.platform === "win32") {
|
|
try {
|
|
terminateProcessTree(this.proc.pid);
|
|
} catch {
|
|
// Best-effort cleanup inside an unref'd timer — swallow errors
|
|
// to avoid crashing the host process during shutdown.
|
|
}
|
|
} else {
|
|
this.proc.kill("SIGTERM");
|
|
}
|
|
}
|
|
}, 50).unref?.();
|
|
}
|
|
|
|
await this.exitPromise;
|
|
}
|
|
|
|
sendMessage(message) {
|
|
const line = `${JSON.stringify(message)}\n`;
|
|
const stdin = this.proc?.stdin;
|
|
if (!stdin) {
|
|
throw new Error("codex app-server stdin is not available.");
|
|
}
|
|
stdin.write(line);
|
|
}
|
|
}
|
|
|
|
class BrokerCodexAppServerClient extends AppServerClientBase {
|
|
constructor(cwd, options = {}) {
|
|
super(cwd, options);
|
|
this.transport = "broker";
|
|
this.endpoint = options.brokerEndpoint;
|
|
}
|
|
|
|
async initialize() {
|
|
await new Promise((resolve, reject) => {
|
|
const target = parseBrokerEndpoint(this.endpoint);
|
|
this.socket = net.createConnection({ path: target.path });
|
|
this.socket.setEncoding("utf8");
|
|
this.socket.on("connect", resolve);
|
|
this.socket.on("data", (chunk) => {
|
|
this.handleChunk(chunk);
|
|
});
|
|
this.socket.on("error", (error) => {
|
|
if (!this.exitResolved) {
|
|
reject(error);
|
|
}
|
|
this.handleExit(error);
|
|
});
|
|
this.socket.on("close", () => {
|
|
this.handleExit(this.exitError);
|
|
});
|
|
});
|
|
|
|
await this.request("initialize", {
|
|
clientInfo: this.options.clientInfo ?? DEFAULT_CLIENT_INFO,
|
|
capabilities: this.options.capabilities ?? DEFAULT_CAPABILITIES
|
|
});
|
|
this.notify("initialized", {});
|
|
}
|
|
|
|
async close() {
|
|
if (this.closed) {
|
|
await this.exitPromise;
|
|
return;
|
|
}
|
|
|
|
this.closed = true;
|
|
if (this.socket) {
|
|
this.socket.end();
|
|
}
|
|
await this.exitPromise;
|
|
}
|
|
|
|
sendMessage(message) {
|
|
const line = `${JSON.stringify(message)}\n`;
|
|
const socket = this.socket;
|
|
if (!socket) {
|
|
throw new Error("codex app-server broker connection is not connected.");
|
|
}
|
|
socket.write(line);
|
|
}
|
|
}
|
|
|
|
export class CodexAppServerClient {
|
|
static async connect(cwd, options = {}) {
|
|
let brokerEndpoint = null;
|
|
if (!options.disableBroker) {
|
|
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
|
|
if (!brokerEndpoint) {
|
|
const brokerSession = await ensureBrokerSession(cwd, { env: options.env });
|
|
brokerEndpoint = brokerSession?.endpoint ?? null;
|
|
}
|
|
}
|
|
const client = brokerEndpoint
|
|
? new BrokerCodexAppServerClient(cwd, { ...options, brokerEndpoint })
|
|
: new SpawnedCodexAppServerClient(cwd, options);
|
|
await client.initialize();
|
|
return client;
|
|
}
|
|
}
|