chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import { parseArgs } from "./lib/args.mjs";
|
||||
import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs";
|
||||
import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs";
|
||||
|
||||
const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);
|
||||
|
||||
function buildStreamThreadIds(method, params, result) {
|
||||
const threadIds = new Set();
|
||||
if (params?.threadId) {
|
||||
threadIds.add(params.threadId);
|
||||
}
|
||||
if (method === "review/start" && result?.reviewThreadId) {
|
||||
threadIds.add(result.reviewThreadId);
|
||||
}
|
||||
return threadIds;
|
||||
}
|
||||
|
||||
function buildJsonRpcError(code, message, data) {
|
||||
return data === undefined ? { code, message } : { code, message, data };
|
||||
}
|
||||
|
||||
function send(socket, message) {
|
||||
if (socket.destroyed) {
|
||||
return;
|
||||
}
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
function isInterruptRequest(message) {
|
||||
return message?.method === "turn/interrupt";
|
||||
}
|
||||
|
||||
function writePidFile(pidFile) {
|
||||
if (!pidFile) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
||||
fs.writeFileSync(pidFile, `${process.pid}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [subcommand, ...argv] = process.argv.slice(2);
|
||||
if (subcommand !== "serve") {
|
||||
throw new Error("Usage: node scripts/app-server-broker.mjs serve --endpoint <value> [--cwd <path>] [--pid-file <path>]");
|
||||
}
|
||||
|
||||
const { options } = parseArgs(argv, {
|
||||
valueOptions: ["cwd", "pid-file", "endpoint"]
|
||||
});
|
||||
|
||||
if (!options.endpoint) {
|
||||
throw new Error("Missing required --endpoint.");
|
||||
}
|
||||
|
||||
const cwd = options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd();
|
||||
const endpoint = String(options.endpoint);
|
||||
const listenTarget = parseBrokerEndpoint(endpoint);
|
||||
const pidFile = options["pid-file"] ? path.resolve(options["pid-file"]) : null;
|
||||
writePidFile(pidFile);
|
||||
|
||||
const appClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
||||
let activeRequestSocket = null;
|
||||
let activeStreamSocket = null;
|
||||
let activeStreamThreadIds = null;
|
||||
const sockets = new Set();
|
||||
|
||||
function clearSocketOwnership(socket) {
|
||||
if (activeRequestSocket === socket) {
|
||||
activeRequestSocket = null;
|
||||
}
|
||||
if (activeStreamSocket === socket) {
|
||||
activeStreamSocket = null;
|
||||
activeStreamThreadIds = null;
|
||||
}
|
||||
}
|
||||
|
||||
function routeNotification(message) {
|
||||
const target = activeRequestSocket ?? activeStreamSocket;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
send(target, message);
|
||||
if (message.method === "turn/completed" && activeStreamSocket === target) {
|
||||
const threadId = message.params?.threadId ?? null;
|
||||
if (!threadId || !activeStreamThreadIds || activeStreamThreadIds.has(threadId)) {
|
||||
activeStreamSocket = null;
|
||||
activeStreamThreadIds = null;
|
||||
if (activeRequestSocket === target) {
|
||||
activeRequestSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(server) {
|
||||
for (const socket of sockets) {
|
||||
socket.end();
|
||||
}
|
||||
await appClient.close().catch(() => {});
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) {
|
||||
fs.unlinkSync(listenTarget.path);
|
||||
}
|
||||
if (pidFile && fs.existsSync(pidFile)) {
|
||||
fs.unlinkSync(pidFile);
|
||||
}
|
||||
}
|
||||
|
||||
appClient.setNotificationHandler(routeNotification);
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.setEncoding("utf8");
|
||||
let buffer = "";
|
||||
|
||||
socket.on("data", async (chunk) => {
|
||||
buffer += chunk;
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch (error) {
|
||||
send(socket, {
|
||||
id: null,
|
||||
error: buildJsonRpcError(-32700, `Invalid JSON: ${error.message}`)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.id !== undefined && message.method === "initialize") {
|
||||
send(socket, {
|
||||
id: message.id,
|
||||
result: {
|
||||
userAgent: "codex-companion-broker"
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.method === "initialized" && message.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.id !== undefined && message.method === "broker/shutdown") {
|
||||
send(socket, { id: message.id, result: {} });
|
||||
await shutdown(server);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (message.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowInterruptDuringActiveStream =
|
||||
isInterruptRequest(message) && activeStreamSocket && activeStreamSocket !== socket && !activeRequestSocket;
|
||||
|
||||
if (
|
||||
((activeRequestSocket && activeRequestSocket !== socket) || (activeStreamSocket && activeStreamSocket !== socket)) &&
|
||||
!allowInterruptDuringActiveStream
|
||||
) {
|
||||
send(socket, {
|
||||
id: message.id,
|
||||
error: buildJsonRpcError(BROKER_BUSY_RPC_CODE, "Shared Codex broker is busy.")
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowInterruptDuringActiveStream) {
|
||||
try {
|
||||
const result = await appClient.request(message.method, message.params ?? {});
|
||||
send(socket, { id: message.id, result });
|
||||
} catch (error) {
|
||||
send(socket, {
|
||||
id: message.id,
|
||||
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const isStreaming = STREAMING_METHODS.has(message.method);
|
||||
activeRequestSocket = socket;
|
||||
|
||||
try {
|
||||
const result = await appClient.request(message.method, message.params ?? {});
|
||||
send(socket, { id: message.id, result });
|
||||
if (isStreaming) {
|
||||
activeStreamSocket = socket;
|
||||
activeStreamThreadIds = buildStreamThreadIds(message.method, message.params ?? {}, result);
|
||||
}
|
||||
if (activeRequestSocket === socket) {
|
||||
activeRequestSocket = null;
|
||||
}
|
||||
} catch (error) {
|
||||
send(socket, {
|
||||
id: message.id,
|
||||
error: buildJsonRpcError(error.rpcCode ?? -32000, error.message)
|
||||
});
|
||||
if (activeRequestSocket === socket) {
|
||||
activeRequestSocket = null;
|
||||
}
|
||||
if (activeStreamSocket === socket && !isStreaming) {
|
||||
activeStreamSocket = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => {
|
||||
sockets.delete(socket);
|
||||
clearSocketOwnership(socket);
|
||||
});
|
||||
|
||||
socket.on("error", () => {
|
||||
sockets.delete(socket);
|
||||
clearSocketOwnership(socket);
|
||||
});
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
await shutdown(server);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await shutdown(server);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
server.listen(listenTarget.path);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
ClientInfo,
|
||||
InitializeCapabilities,
|
||||
InitializeParams,
|
||||
InitializeResponse,
|
||||
ServerNotification
|
||||
} from "../../.generated/app-server-types/index.js";
|
||||
import type {
|
||||
ExternalAgentConfigImportParams,
|
||||
ExternalAgentConfigImportResponse,
|
||||
ReviewStartParams,
|
||||
ReviewStartResponse,
|
||||
ReviewTarget,
|
||||
Thread,
|
||||
ThreadItem,
|
||||
ThreadListParams,
|
||||
ThreadListResponse,
|
||||
ThreadResumeParams as RawThreadResumeParams,
|
||||
ThreadResumeResponse,
|
||||
ThreadSetNameParams,
|
||||
ThreadSetNameResponse,
|
||||
ThreadStartParams as RawThreadStartParams,
|
||||
ThreadStartResponse,
|
||||
Turn,
|
||||
TurnInterruptParams,
|
||||
TurnInterruptResponse,
|
||||
TurnStartParams,
|
||||
TurnStartResponse,
|
||||
UserInput
|
||||
} from "../../.generated/app-server-types/v2/index.js";
|
||||
|
||||
export type {
|
||||
ClientInfo,
|
||||
InitializeCapabilities,
|
||||
InitializeParams,
|
||||
InitializeResponse,
|
||||
ReviewTarget,
|
||||
Thread,
|
||||
ThreadItem,
|
||||
ThreadListParams,
|
||||
Turn,
|
||||
TurnInterruptParams,
|
||||
TurnStartParams,
|
||||
UserInput
|
||||
};
|
||||
|
||||
export type ThreadStartParams = Omit<RawThreadStartParams, "persistExtendedHistory">;
|
||||
export type ThreadResumeParams = Omit<RawThreadResumeParams, "persistExtendedHistory">;
|
||||
|
||||
export interface CodexAppServerClientOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
clientInfo?: ClientInfo;
|
||||
capabilities?: InitializeCapabilities;
|
||||
brokerEndpoint?: string;
|
||||
disableBroker?: boolean;
|
||||
reuseExistingBroker?: boolean;
|
||||
}
|
||||
|
||||
export interface AppServerMethodMap {
|
||||
initialize: { params: InitializeParams; result: InitializeResponse };
|
||||
"externalAgentConfig/import": { params: ExternalAgentConfigImportParams; result: ExternalAgentConfigImportResponse };
|
||||
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
|
||||
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
|
||||
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
|
||||
"thread/list": { params: ThreadListParams; result: ThreadListResponse };
|
||||
"review/start": { params: ReviewStartParams; result: ReviewStartResponse };
|
||||
"turn/start": { params: TurnStartParams; result: TurnStartResponse };
|
||||
"turn/interrupt": { params: TurnInterruptParams; result: TurnInterruptResponse };
|
||||
}
|
||||
|
||||
export type AppServerMethod = keyof AppServerMethodMap;
|
||||
export type AppServerRequestParams<M extends AppServerMethod> = AppServerMethodMap[M]["params"];
|
||||
export type AppServerResponse<M extends AppServerMethod> = AppServerMethodMap[M]["result"];
|
||||
export type AppServerNotification = ServerNotification;
|
||||
export type AppServerNotificationHandler = (message: AppServerNotification) => void;
|
||||
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* @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, loadBrokerSession } 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,
|
||||
requestAttestation: 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 ?? process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
|
||||
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 stderr = this.stderr.trim();
|
||||
const detail =
|
||||
code === 0
|
||||
? null
|
||||
: createProtocolError(
|
||||
`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${stderr ? `\n${stderr}` : ""}`
|
||||
);
|
||||
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 && options.reuseExistingBroker) {
|
||||
brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null;
|
||||
}
|
||||
if (!brokerEndpoint && !options.reuseExistingBroker) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
export function parseArgs(argv, config = {}) {
|
||||
const valueOptions = new Set(config.valueOptions ?? []);
|
||||
const booleanOptions = new Set(config.booleanOptions ?? []);
|
||||
const aliasMap = config.aliasMap ?? {};
|
||||
const options = {};
|
||||
const positionals = [];
|
||||
let passthrough = false;
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
|
||||
if (passthrough) {
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token === "--") {
|
||||
passthrough = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token.startsWith("-") || token === "-") {
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("--")) {
|
||||
const [rawKey, inlineValue] = token.slice(2).split("=", 2);
|
||||
const key = aliasMap[rawKey] ?? rawKey;
|
||||
|
||||
if (booleanOptions.has(key)) {
|
||||
options[key] = inlineValue === undefined ? true : inlineValue !== "false";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueOptions.has(key)) {
|
||||
const nextValue = inlineValue ?? argv[index + 1];
|
||||
if (nextValue === undefined) {
|
||||
throw new Error(`Missing value for --${rawKey}`);
|
||||
}
|
||||
options[key] = nextValue;
|
||||
if (inlineValue === undefined) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortKey = token.slice(1);
|
||||
const key = aliasMap[shortKey] ?? shortKey;
|
||||
|
||||
if (booleanOptions.has(key)) {
|
||||
options[key] = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueOptions.has(key)) {
|
||||
const nextValue = argv[index + 1];
|
||||
if (nextValue === undefined) {
|
||||
throw new Error(`Missing value for -${shortKey}`);
|
||||
}
|
||||
options[key] = nextValue;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
positionals.push(token);
|
||||
}
|
||||
|
||||
return { options, positionals };
|
||||
}
|
||||
|
||||
export function splitRawArgumentString(raw) {
|
||||
const tokens = [];
|
||||
let current = "";
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (const character of raw) {
|
||||
if (escaping) {
|
||||
current += character;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "'" || character === "\"") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(character)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += character;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
function sanitizePipeName(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/[^A-Za-z0-9._-]/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function createBrokerEndpoint(sessionDir, platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
const pipeName = sanitizePipeName(`${path.win32.basename(sessionDir)}-codex-app-server`);
|
||||
return `pipe:\\\\.\\pipe\\${pipeName}`;
|
||||
}
|
||||
|
||||
return `unix:${path.join(sessionDir, "broker.sock")}`;
|
||||
}
|
||||
|
||||
export function parseBrokerEndpoint(endpoint) {
|
||||
if (typeof endpoint !== "string" || endpoint.length === 0) {
|
||||
throw new Error("Missing broker endpoint.");
|
||||
}
|
||||
|
||||
if (endpoint.startsWith("pipe:")) {
|
||||
const pipePath = endpoint.slice("pipe:".length);
|
||||
if (!pipePath) {
|
||||
throw new Error("Broker pipe endpoint is missing its path.");
|
||||
}
|
||||
return { kind: "pipe", path: pipePath };
|
||||
}
|
||||
|
||||
if (endpoint.startsWith("unix:")) {
|
||||
const socketPath = endpoint.slice("unix:".length);
|
||||
if (!socketPath) {
|
||||
throw new Error("Broker Unix socket endpoint is missing its path.");
|
||||
}
|
||||
return { kind: "unix", path: socketPath };
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported broker endpoint: ${endpoint}`);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createBrokerEndpoint, parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
||||
import { resolveStateDir } from "./state.mjs";
|
||||
|
||||
export const PID_FILE_ENV = "CODEX_COMPANION_APP_SERVER_PID_FILE";
|
||||
export const LOG_FILE_ENV = "CODEX_COMPANION_APP_SERVER_LOG_FILE";
|
||||
const BROKER_STATE_FILE = "broker.json";
|
||||
|
||||
export function createBrokerSessionDir(prefix = "cxc-") {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function connectToEndpoint(endpoint) {
|
||||
const target = parseBrokerEndpoint(endpoint);
|
||||
return net.createConnection({ path: target.path });
|
||||
}
|
||||
|
||||
export async function waitForBrokerEndpoint(endpoint, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const ready = await new Promise((resolve) => {
|
||||
const socket = connectToEndpoint(endpoint);
|
||||
socket.on("connect", () => {
|
||||
socket.end();
|
||||
resolve(true);
|
||||
});
|
||||
socket.on("error", () => resolve(false));
|
||||
});
|
||||
if (ready) {
|
||||
return true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function sendBrokerShutdown(endpoint) {
|
||||
await new Promise((resolve) => {
|
||||
const socket = connectToEndpoint(endpoint);
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("connect", () => {
|
||||
socket.write(`${JSON.stringify({ id: 1, method: "broker/shutdown", params: {} })}\n`);
|
||||
});
|
||||
socket.on("data", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.on("error", resolve);
|
||||
socket.on("close", resolve);
|
||||
});
|
||||
}
|
||||
|
||||
export function spawnBrokerProcess({ scriptPath, cwd, endpoint, pidFile, logFile, env = process.env }) {
|
||||
const logFd = fs.openSync(logFile, "a");
|
||||
const child = spawn(process.execPath, [scriptPath, "serve", "--endpoint", endpoint, "--cwd", cwd, "--pid-file", pidFile], {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd]
|
||||
});
|
||||
child.unref();
|
||||
fs.closeSync(logFd);
|
||||
return child;
|
||||
}
|
||||
|
||||
function resolveBrokerStateFile(cwd) {
|
||||
return path.join(resolveStateDir(cwd), BROKER_STATE_FILE);
|
||||
}
|
||||
|
||||
export function loadBrokerSession(cwd) {
|
||||
const stateFile = resolveBrokerStateFile(cwd);
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveBrokerSession(cwd, session) {
|
||||
const stateDir = resolveStateDir(cwd);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.writeFileSync(resolveBrokerStateFile(cwd), `${JSON.stringify(session, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function clearBrokerSession(cwd) {
|
||||
const stateFile = resolveBrokerStateFile(cwd);
|
||||
if (fs.existsSync(stateFile)) {
|
||||
fs.unlinkSync(stateFile);
|
||||
}
|
||||
}
|
||||
|
||||
async function isBrokerEndpointReady(endpoint) {
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await waitForBrokerEndpoint(endpoint, 150);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBrokerSession(cwd, options = {}) {
|
||||
const existing = loadBrokerSession(cwd);
|
||||
if (existing && (await isBrokerEndpointReady(existing.endpoint))) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
teardownBrokerSession({
|
||||
endpoint: existing.endpoint ?? null,
|
||||
pidFile: existing.pidFile ?? null,
|
||||
logFile: existing.logFile ?? null,
|
||||
sessionDir: existing.sessionDir ?? null,
|
||||
pid: existing.pid ?? null,
|
||||
killProcess: options.killProcess ?? null
|
||||
});
|
||||
clearBrokerSession(cwd);
|
||||
}
|
||||
|
||||
const sessionDir = createBrokerSessionDir();
|
||||
const endpointFactory = options.createBrokerEndpoint ?? createBrokerEndpoint;
|
||||
const endpoint = endpointFactory(sessionDir, options.platform);
|
||||
const pidFile = path.join(sessionDir, "broker.pid");
|
||||
const logFile = path.join(sessionDir, "broker.log");
|
||||
const scriptPath =
|
||||
options.scriptPath ??
|
||||
fileURLToPath(new URL("../app-server-broker.mjs", import.meta.url));
|
||||
|
||||
const child = spawnBrokerProcess({
|
||||
scriptPath,
|
||||
cwd,
|
||||
endpoint,
|
||||
pidFile,
|
||||
logFile,
|
||||
env: options.env ?? process.env
|
||||
});
|
||||
|
||||
const ready = await waitForBrokerEndpoint(endpoint, options.timeoutMs ?? 2000);
|
||||
if (!ready) {
|
||||
teardownBrokerSession({
|
||||
endpoint,
|
||||
pidFile,
|
||||
logFile,
|
||||
sessionDir,
|
||||
pid: child.pid ?? null,
|
||||
killProcess: options.killProcess ?? null
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = {
|
||||
endpoint,
|
||||
pidFile,
|
||||
logFile,
|
||||
sessionDir,
|
||||
pid: child.pid ?? null
|
||||
};
|
||||
saveBrokerSession(cwd, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function teardownBrokerSession({ endpoint = null, pidFile, logFile, sessionDir = null, pid = null, killProcess = null }) {
|
||||
if (Number.isFinite(pid) && killProcess) {
|
||||
try {
|
||||
killProcess(pid);
|
||||
} catch {
|
||||
// Ignore missing or already-exited broker processes.
|
||||
}
|
||||
}
|
||||
|
||||
if (pidFile && fs.existsSync(pidFile)) {
|
||||
fs.unlinkSync(pidFile);
|
||||
}
|
||||
|
||||
if (logFile && fs.existsSync(logFile)) {
|
||||
fs.unlinkSync(logFile);
|
||||
}
|
||||
|
||||
if (endpoint) {
|
||||
try {
|
||||
const target = parseBrokerEndpoint(endpoint);
|
||||
if (target.kind === "unix" && fs.existsSync(target.path)) {
|
||||
fs.unlinkSync(target.path);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed or already-removed broker endpoints during teardown.
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedSessionDir = sessionDir ?? (pidFile ? path.dirname(pidFile) : logFile ? path.dirname(logFile) : null);
|
||||
if (resolvedSessionDir && fs.existsSync(resolvedSessionDir)) {
|
||||
try {
|
||||
fs.rmdirSync(resolvedSessionDir);
|
||||
} catch {
|
||||
// Ignore non-empty or missing directories.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { ensureAbsolutePath } from "./fs.mjs";
|
||||
|
||||
export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH";
|
||||
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
||||
|
||||
function resolveUserPath(cwd, value) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (String(value).startsWith("~/")) {
|
||||
return path.join(os.homedir(), String(value).slice(2));
|
||||
}
|
||||
return ensureAbsolutePath(cwd, value);
|
||||
}
|
||||
|
||||
export function resolveClaudeSessionPath(cwd, options = {}) {
|
||||
const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
|
||||
if (!requestedPath) {
|
||||
throw new Error("Could not identify the current Claude transcript. Retry with --source <path-to-claude-jsonl>.");
|
||||
}
|
||||
|
||||
const sourcePath = resolveUserPath(cwd, requestedPath);
|
||||
if (path.extname(sourcePath) !== ".jsonl") {
|
||||
throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`);
|
||||
}
|
||||
|
||||
let source;
|
||||
let projects;
|
||||
try {
|
||||
source = fs.realpathSync(sourcePath);
|
||||
projects = fs.realpathSync(CLAUDE_PROJECTS_DIR);
|
||||
} catch {
|
||||
throw new Error(`Claude session file not found: ${sourcePath}`);
|
||||
}
|
||||
const relative = path.relative(projects, source);
|
||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function ensureAbsolutePath(cwd, maybePath) {
|
||||
return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath);
|
||||
}
|
||||
|
||||
export function createTempDir(prefix = "codex-plugin-") {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
export function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
export function writeJsonFile(filePath, value) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function safeReadFile(filePath) {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
}
|
||||
|
||||
export function isProbablyText(buffer) {
|
||||
const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
|
||||
for (const value of sample) {
|
||||
if (value === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readStdinIfPiped() {
|
||||
if (process.stdin.isTTY) {
|
||||
return "";
|
||||
}
|
||||
return fs.readFileSync(0, "utf8");
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { isProbablyText } from "./fs.mjs";
|
||||
import { formatCommandFailure, runCommand, runCommandChecked } from "./process.mjs";
|
||||
|
||||
const MAX_UNTRACKED_BYTES = 24 * 1024;
|
||||
const DEFAULT_INLINE_DIFF_MAX_FILES = 2;
|
||||
const DEFAULT_INLINE_DIFF_MAX_BYTES = 256 * 1024;
|
||||
|
||||
// Git is directly executable on Windows. Repository-derived arguments must never pass through a shell.
|
||||
function git(cwd, args, options = {}) {
|
||||
return runCommand("git", args, { cwd, ...options, shell: false });
|
||||
}
|
||||
|
||||
function gitChecked(cwd, args, options = {}) {
|
||||
return runCommandChecked("git", args, { cwd, ...options, shell: false });
|
||||
}
|
||||
|
||||
function listUniqueFiles(...groups) {
|
||||
return [...new Set(groups.flat().filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function normalizeMaxInlineFiles(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return DEFAULT_INLINE_DIFF_MAX_FILES;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function normalizeMaxInlineDiffBytes(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return DEFAULT_INLINE_DIFF_MAX_BYTES;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function measureGitOutputBytes(cwd, args, maxBytes) {
|
||||
const result = git(cwd, args, { maxBuffer: maxBytes + 1 });
|
||||
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOBUFS") {
|
||||
return maxBytes + 1;
|
||||
}
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(formatCommandFailure(result));
|
||||
}
|
||||
return Buffer.byteLength(result.stdout, "utf8");
|
||||
}
|
||||
|
||||
function measureCombinedGitOutputBytes(cwd, argSets, maxBytes) {
|
||||
let totalBytes = 0;
|
||||
for (const args of argSets) {
|
||||
const remainingBytes = maxBytes - totalBytes;
|
||||
if (remainingBytes < 0) {
|
||||
return maxBytes + 1;
|
||||
}
|
||||
totalBytes += measureGitOutputBytes(cwd, args, remainingBytes);
|
||||
if (totalBytes > maxBytes) {
|
||||
return totalBytes;
|
||||
}
|
||||
}
|
||||
return totalBytes;
|
||||
}
|
||||
|
||||
function buildBranchComparison(cwd, baseRef) {
|
||||
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
||||
return {
|
||||
mergeBase,
|
||||
commitRange: `${mergeBase}..HEAD`,
|
||||
reviewRange: `${baseRef}...HEAD`
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGitRepository(cwd) {
|
||||
const result = git(cwd, ["rev-parse", "--show-toplevel"]);
|
||||
const errorCode = result.error && "code" in result.error ? result.error.code : null;
|
||||
if (errorCode === "ENOENT") {
|
||||
throw new Error("git is not installed. Install Git and retry.");
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error("This command must run inside a Git repository.");
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
export function getRepoRoot(cwd) {
|
||||
return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
|
||||
}
|
||||
|
||||
export function detectDefaultBranch(cwd) {
|
||||
const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
|
||||
if (symbolic.status === 0) {
|
||||
const remoteHead = symbolic.stdout.trim();
|
||||
if (remoteHead.startsWith("refs/remotes/origin/")) {
|
||||
return remoteHead.replace("refs/remotes/origin/", "");
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = ["main", "master", "trunk"];
|
||||
for (const candidate of candidates) {
|
||||
const local = git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`]);
|
||||
if (local.status === 0) {
|
||||
return candidate;
|
||||
}
|
||||
const remote = git(cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`]);
|
||||
if (remote.status === 0) {
|
||||
return `origin/${candidate}`;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Unable to detect the repository default branch. Pass --base <ref> or use --scope working-tree.");
|
||||
}
|
||||
|
||||
export function getCurrentBranch(cwd) {
|
||||
return gitChecked(cwd, ["branch", "--show-current"]).stdout.trim() || "HEAD";
|
||||
}
|
||||
|
||||
export function getWorkingTreeState(cwd) {
|
||||
const staged = gitChecked(cwd, ["diff", "--cached", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
||||
const unstaged = gitChecked(cwd, ["diff", "--name-only"]).stdout.trim().split("\n").filter(Boolean);
|
||||
const untracked = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean);
|
||||
|
||||
return {
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
isDirty: staged.length > 0 || unstaged.length > 0 || untracked.length > 0
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReviewTarget(cwd, options = {}) {
|
||||
ensureGitRepository(cwd);
|
||||
|
||||
const requestedScope = options.scope ?? "auto";
|
||||
const baseRef = options.base ?? null;
|
||||
const state = getWorkingTreeState(cwd);
|
||||
const supportedScopes = new Set(["auto", "working-tree", "branch"]);
|
||||
|
||||
if (baseRef) {
|
||||
return {
|
||||
mode: "branch",
|
||||
label: `branch diff against ${baseRef}`,
|
||||
baseRef,
|
||||
explicit: true
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedScope === "working-tree") {
|
||||
return {
|
||||
mode: "working-tree",
|
||||
label: "working tree diff",
|
||||
explicit: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!supportedScopes.has(requestedScope)) {
|
||||
throw new Error(
|
||||
`Unsupported review scope "${requestedScope}". Use one of: auto, working-tree, branch, or pass --base <ref>.`
|
||||
);
|
||||
}
|
||||
|
||||
if (requestedScope === "branch") {
|
||||
const detectedBase = detectDefaultBranch(cwd);
|
||||
return {
|
||||
mode: "branch",
|
||||
label: `branch diff against ${detectedBase}`,
|
||||
baseRef: detectedBase,
|
||||
explicit: true
|
||||
};
|
||||
}
|
||||
|
||||
if (state.isDirty) {
|
||||
return {
|
||||
mode: "working-tree",
|
||||
label: "working tree diff",
|
||||
explicit: false
|
||||
};
|
||||
}
|
||||
|
||||
const detectedBase = detectDefaultBranch(cwd);
|
||||
return {
|
||||
mode: "branch",
|
||||
label: `branch diff against ${detectedBase}`,
|
||||
baseRef: detectedBase,
|
||||
explicit: false
|
||||
};
|
||||
}
|
||||
|
||||
function formatSection(title, body) {
|
||||
return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n");
|
||||
}
|
||||
|
||||
function formatUntrackedFile(cwd, relativePath) {
|
||||
const absolutePath = path.join(cwd, relativePath);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(absolutePath);
|
||||
} catch {
|
||||
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
return `### ${relativePath}\n(skipped: directory)`;
|
||||
}
|
||||
if (stat.size > MAX_UNTRACKED_BYTES) {
|
||||
return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`;
|
||||
}
|
||||
|
||||
let buffer;
|
||||
try {
|
||||
buffer = fs.readFileSync(absolutePath);
|
||||
} catch {
|
||||
return `### ${relativePath}\n(skipped: broken symlink or unreadable file)`;
|
||||
}
|
||||
if (!isProbablyText(buffer)) {
|
||||
return `### ${relativePath}\n(skipped: binary file)`;
|
||||
}
|
||||
|
||||
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
|
||||
}
|
||||
|
||||
function collectWorkingTreeContext(cwd, state, options = {}) {
|
||||
const includeDiff = options.includeDiff !== false;
|
||||
const status = gitChecked(cwd, ["status", "--short", "--untracked-files=all"]).stdout.trim();
|
||||
const changedFiles = listUniqueFiles(state.staged, state.unstaged, state.untracked);
|
||||
|
||||
let parts;
|
||||
if (includeDiff) {
|
||||
const stagedDiff = gitChecked(cwd, ["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const unstagedDiff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff"]).stdout;
|
||||
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
||||
parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff", stagedDiff),
|
||||
formatSection("Unstaged Diff", unstagedDiff),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
} else {
|
||||
const stagedStat = gitChecked(cwd, ["diff", "--shortstat", "--cached"]).stdout.trim();
|
||||
const unstagedStat = gitChecked(cwd, ["diff", "--shortstat"]).stdout.trim();
|
||||
const untrackedBody = state.untracked.map((file) => formatUntrackedFile(cwd, file)).join("\n\n");
|
||||
parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff Stat", stagedStat),
|
||||
formatSection("Unstaged Diff Stat", unstagedStat),
|
||||
formatSection("Changed Files", changedFiles.join("\n")),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
mode: "working-tree",
|
||||
summary: `Reviewing ${state.staged.length} staged, ${state.unstaged.length} unstaged, and ${state.untracked.length} untracked file(s).`,
|
||||
content: parts.join("\n"),
|
||||
changedFiles
|
||||
};
|
||||
}
|
||||
|
||||
function collectBranchContext(cwd, baseRef, options = {}) {
|
||||
const includeDiff = options.includeDiff !== false;
|
||||
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
|
||||
const currentBranch = getCurrentBranch(cwd);
|
||||
const changedFiles = gitChecked(cwd, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean);
|
||||
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", comparison.commitRange]).stdout.trim();
|
||||
const diffStat = gitChecked(cwd, ["diff", "--stat", comparison.commitRange]).stdout.trim();
|
||||
|
||||
return {
|
||||
mode: "branch",
|
||||
summary: `Reviewing branch ${currentBranch} against ${baseRef} from merge-base ${comparison.mergeBase}.`,
|
||||
content: includeDiff
|
||||
? [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection(
|
||||
"Branch Diff",
|
||||
gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange]).stdout
|
||||
)
|
||||
].join("\n")
|
||||
: [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection("Changed Files", changedFiles.join("\n"))
|
||||
].join("\n"),
|
||||
changedFiles,
|
||||
comparison
|
||||
};
|
||||
}
|
||||
|
||||
function buildAdversarialCollectionGuidance(options = {}) {
|
||||
if (options.includeDiff !== false) {
|
||||
return "Use the repository context below as primary evidence.";
|
||||
}
|
||||
|
||||
return "The repository context below is a lightweight summary. Inspect the target diff yourself with read-only git commands before finalizing findings.";
|
||||
}
|
||||
|
||||
export function collectReviewContext(cwd, target, options = {}) {
|
||||
const repoRoot = getRepoRoot(cwd);
|
||||
const currentBranch = getCurrentBranch(repoRoot);
|
||||
const maxInlineFiles = normalizeMaxInlineFiles(options.maxInlineFiles);
|
||||
const maxInlineDiffBytes = normalizeMaxInlineDiffBytes(options.maxInlineDiffBytes);
|
||||
let details;
|
||||
let includeDiff;
|
||||
let diffBytes;
|
||||
|
||||
if (target.mode === "working-tree") {
|
||||
const state = getWorkingTreeState(repoRoot);
|
||||
diffBytes = measureCombinedGitOutputBytes(
|
||||
repoRoot,
|
||||
[
|
||||
["diff", "--cached", "--binary", "--no-ext-diff", "--submodule=diff"],
|
||||
["diff", "--binary", "--no-ext-diff", "--submodule=diff"]
|
||||
],
|
||||
maxInlineDiffBytes
|
||||
);
|
||||
includeDiff =
|
||||
options.includeDiff ??
|
||||
(listUniqueFiles(state.staged, state.unstaged, state.untracked).length <= maxInlineFiles &&
|
||||
diffBytes <= maxInlineDiffBytes);
|
||||
details = collectWorkingTreeContext(repoRoot, state, { includeDiff });
|
||||
} else {
|
||||
const comparison = buildBranchComparison(repoRoot, target.baseRef);
|
||||
const fileCount = gitChecked(repoRoot, ["diff", "--name-only", comparison.commitRange]).stdout.trim().split("\n").filter(Boolean).length;
|
||||
diffBytes = measureGitOutputBytes(
|
||||
repoRoot,
|
||||
["diff", "--binary", "--no-ext-diff", "--submodule=diff", comparison.commitRange],
|
||||
maxInlineDiffBytes
|
||||
);
|
||||
includeDiff = options.includeDiff ?? (fileCount <= maxInlineFiles && diffBytes <= maxInlineDiffBytes);
|
||||
details = collectBranchContext(repoRoot, target.baseRef, { includeDiff, comparison });
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: repoRoot,
|
||||
repoRoot,
|
||||
branch: currentBranch,
|
||||
target,
|
||||
fileCount: details.changedFiles.length,
|
||||
diffBytes,
|
||||
inputMode: includeDiff ? "inline-diff" : "self-collect",
|
||||
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
|
||||
...details
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import { getSessionRuntimeStatus } from "./codex.mjs";
|
||||
import { getConfig, listJobs, readJobFile, resolveJobFile } from "./state.mjs";
|
||||
import { SESSION_ID_ENV } from "./tracked-jobs.mjs";
|
||||
import { resolveWorkspaceRoot } from "./workspace.mjs";
|
||||
|
||||
export const DEFAULT_MAX_STATUS_JOBS = 8;
|
||||
export const DEFAULT_MAX_PROGRESS_LINES = 4;
|
||||
|
||||
export function sortJobsNewestFirst(jobs) {
|
||||
return [...jobs].sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")));
|
||||
}
|
||||
|
||||
function getCurrentSessionId(options = {}) {
|
||||
return options.env?.[SESSION_ID_ENV] ?? process.env[SESSION_ID_ENV] ?? null;
|
||||
}
|
||||
|
||||
function filterJobsForCurrentSession(jobs, options = {}) {
|
||||
const sessionId = getCurrentSessionId(options);
|
||||
if (!sessionId) {
|
||||
return jobs;
|
||||
}
|
||||
return jobs.filter((job) => job.sessionId === sessionId);
|
||||
}
|
||||
|
||||
function getJobTypeLabel(job) {
|
||||
if (typeof job.kindLabel === "string" && job.kindLabel) {
|
||||
return job.kindLabel;
|
||||
}
|
||||
if (job.kind === "adversarial-review") {
|
||||
return "adversarial-review";
|
||||
}
|
||||
if (job.jobClass === "review") {
|
||||
return "review";
|
||||
}
|
||||
if (job.jobClass === "task") {
|
||||
return "rescue";
|
||||
}
|
||||
if (job.kind === "review") {
|
||||
return "review";
|
||||
}
|
||||
if (job.kind === "task") {
|
||||
return "rescue";
|
||||
}
|
||||
return "job";
|
||||
}
|
||||
|
||||
function stripLogPrefix(line) {
|
||||
return line.replace(/^\[[^\]]+\]\s*/, "").trim();
|
||||
}
|
||||
|
||||
function isProgressBlockTitle(line) {
|
||||
return (
|
||||
["Final output", "Assistant message", "Reasoning summary", "Review output"].includes(line) ||
|
||||
/^Subagent .+ message$/.test(line) ||
|
||||
/^Subagent .+ reasoning summary$/.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
export function readJobProgressPreview(logFile, maxLines = DEFAULT_MAX_PROGRESS_LINES) {
|
||||
if (!logFile || !fs.existsSync(logFile)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = fs
|
||||
.readFileSync(logFile, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter(Boolean)
|
||||
.filter((line) => line.startsWith("["))
|
||||
.map(stripLogPrefix)
|
||||
.filter((line) => line && !isProgressBlockTitle(line));
|
||||
|
||||
return lines.slice(-maxLines);
|
||||
}
|
||||
|
||||
function formatElapsedDuration(startValue, endValue = null) {
|
||||
const start = Date.parse(startValue ?? "");
|
||||
if (!Number.isFinite(start)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const end = endValue ? Date.parse(endValue) : Date.now();
|
||||
if (!Number.isFinite(end) || end < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round((end - start) / 1000));
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function looksLikeVerificationCommand(line) {
|
||||
return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
|
||||
line
|
||||
);
|
||||
}
|
||||
|
||||
function inferLegacyJobPhase(job, progressPreview = []) {
|
||||
switch (job.status) {
|
||||
case "queued":
|
||||
return "queued";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "completed":
|
||||
return "done";
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
for (let index = progressPreview.length - 1; index >= 0; index -= 1) {
|
||||
const line = progressPreview[index].toLowerCase();
|
||||
if (line.startsWith("starting codex") || line.startsWith("thread ready") || line.startsWith("turn started")) {
|
||||
return "starting";
|
||||
}
|
||||
if (line.startsWith("reviewer started") || line.includes("review mode")) {
|
||||
return "reviewing";
|
||||
}
|
||||
if (line.startsWith("searching:") || line.startsWith("calling ") || line.startsWith("running tool:")) {
|
||||
return "investigating";
|
||||
}
|
||||
if (line.startsWith("starting collaboration tool:")) {
|
||||
return "investigating";
|
||||
}
|
||||
if (line.startsWith("running command:")) {
|
||||
return looksLikeVerificationCommand(line)
|
||||
? "verifying"
|
||||
: job.jobClass === "review"
|
||||
? "reviewing"
|
||||
: "investigating";
|
||||
}
|
||||
if (line.startsWith("command completed:")) {
|
||||
return looksLikeVerificationCommand(line) ? "verifying" : "running";
|
||||
}
|
||||
if (line.startsWith("applying ") || line.startsWith("file changes ")) {
|
||||
return "editing";
|
||||
}
|
||||
if (line.startsWith("turn completed")) {
|
||||
return "finalizing";
|
||||
}
|
||||
if (line.startsWith("codex error:") || line.startsWith("failed:")) {
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
return job.jobClass === "review" ? "reviewing" : "running";
|
||||
}
|
||||
|
||||
export function enrichJob(job, options = {}) {
|
||||
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
|
||||
const enriched = {
|
||||
...job,
|
||||
kindLabel: getJobTypeLabel(job),
|
||||
progressPreview:
|
||||
job.status === "queued" || job.status === "running" || job.status === "failed"
|
||||
? readJobProgressPreview(job.logFile, maxProgressLines)
|
||||
: [],
|
||||
elapsed: formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? null),
|
||||
duration:
|
||||
job.status === "completed" || job.status === "failed" || job.status === "cancelled"
|
||||
? formatElapsedDuration(job.startedAt ?? job.createdAt, job.completedAt ?? job.updatedAt)
|
||||
: null
|
||||
};
|
||||
|
||||
return {
|
||||
...enriched,
|
||||
phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview)
|
||||
};
|
||||
}
|
||||
|
||||
export function readStoredJob(workspaceRoot, jobId) {
|
||||
const jobFile = resolveJobFile(workspaceRoot, jobId);
|
||||
if (!fs.existsSync(jobFile)) {
|
||||
return null;
|
||||
}
|
||||
return readJobFile(jobFile);
|
||||
}
|
||||
|
||||
function matchJobReference(jobs, reference, predicate = () => true) {
|
||||
const filtered = jobs.filter(predicate);
|
||||
if (!reference) {
|
||||
return filtered[0] ?? null;
|
||||
}
|
||||
|
||||
const exact = filtered.find((job) => job.id === reference);
|
||||
if (exact) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
const prefixMatches = filtered.filter((job) => job.id.startsWith(reference));
|
||||
if (prefixMatches.length === 1) {
|
||||
return prefixMatches[0];
|
||||
}
|
||||
if (prefixMatches.length > 1) {
|
||||
throw new Error(`Job reference "${reference}" is ambiguous. Use a longer job id.`);
|
||||
}
|
||||
|
||||
throw new Error(`No job found for "${reference}". Run /codex:status to list known jobs.`);
|
||||
}
|
||||
|
||||
export function buildStatusSnapshot(cwd, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const config = getConfig(workspaceRoot);
|
||||
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), options));
|
||||
const maxJobs = options.maxJobs ?? DEFAULT_MAX_STATUS_JOBS;
|
||||
const maxProgressLines = options.maxProgressLines ?? DEFAULT_MAX_PROGRESS_LINES;
|
||||
|
||||
const running = jobs
|
||||
.filter((job) => job.status === "queued" || job.status === "running")
|
||||
.map((job) => enrichJob(job, { maxProgressLines }));
|
||||
|
||||
const latestFinishedRaw = jobs.find((job) => job.status !== "queued" && job.status !== "running") ?? null;
|
||||
const latestFinished = latestFinishedRaw ? enrichJob(latestFinishedRaw, { maxProgressLines }) : null;
|
||||
|
||||
const recent = (options.all ? jobs : jobs.slice(0, maxJobs))
|
||||
.filter((job) => job.status !== "queued" && job.status !== "running" && job.id !== latestFinished?.id)
|
||||
.map((job) => enrichJob(job, { maxProgressLines }));
|
||||
|
||||
return {
|
||||
workspaceRoot,
|
||||
config,
|
||||
sessionRuntime: getSessionRuntimeStatus(options.env, workspaceRoot),
|
||||
running,
|
||||
latestFinished,
|
||||
recent,
|
||||
needsReview: Boolean(config.stopReviewGate)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSingleJobSnapshot(cwd, reference, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
||||
const selected = matchJobReference(jobs, reference);
|
||||
if (!selected) {
|
||||
throw new Error(`No job found for "${reference}". Run /codex:status to inspect known jobs.`);
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceRoot,
|
||||
job: enrichJob(selected, { maxProgressLines: options.maxProgressLines })
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveResultJob(cwd, reference) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const jobs = sortJobsNewestFirst(reference ? listJobs(workspaceRoot) : filterJobsForCurrentSession(listJobs(workspaceRoot)));
|
||||
const selected = matchJobReference(
|
||||
jobs,
|
||||
reference,
|
||||
(job) => job.status === "completed" || job.status === "failed" || job.status === "cancelled"
|
||||
);
|
||||
|
||||
if (selected) {
|
||||
return { workspaceRoot, job: selected };
|
||||
}
|
||||
|
||||
const active = matchJobReference(jobs, reference, (job) => job.status === "queued" || job.status === "running");
|
||||
if (active) {
|
||||
throw new Error(`Job ${active.id} is still ${active.status}. Check /codex:status and try again once it finishes.`);
|
||||
}
|
||||
|
||||
if (reference) {
|
||||
throw new Error(`No finished job found for "${reference}". Run /codex:status to inspect active jobs.`);
|
||||
}
|
||||
|
||||
throw new Error("No finished Codex jobs found for this repository yet.");
|
||||
}
|
||||
|
||||
export function resolveCancelableJob(cwd, reference, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
||||
const activeJobs = jobs.filter((job) => job.status === "queued" || job.status === "running");
|
||||
|
||||
if (reference) {
|
||||
const selected = matchJobReference(activeJobs, reference);
|
||||
if (!selected) {
|
||||
throw new Error(`No active job found for "${reference}".`);
|
||||
}
|
||||
return { workspaceRoot, job: selected };
|
||||
}
|
||||
|
||||
const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
|
||||
|
||||
if (sessionScopedActiveJobs.length === 1) {
|
||||
return { workspaceRoot, job: sessionScopedActiveJobs[0] };
|
||||
}
|
||||
if (sessionScopedActiveJobs.length > 1) {
|
||||
throw new Error("Multiple Codex jobs are active. Pass a job id to /codex:cancel.");
|
||||
}
|
||||
|
||||
if (getCurrentSessionId(options)) {
|
||||
throw new Error("No active Codex jobs to cancel for this session.");
|
||||
}
|
||||
|
||||
throw new Error("No active Codex jobs to cancel.");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import process from "node:process";
|
||||
|
||||
export function runCommand(command, args = [], options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
encoding: "utf8",
|
||||
input: options.input,
|
||||
maxBuffer: options.maxBuffer,
|
||||
stdio: options.stdio ?? "pipe",
|
||||
shell: options.shell ?? (process.platform === "win32" ? (process.env.SHELL || true) : false),
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
status: result.status ?? 0,
|
||||
signal: result.signal ?? null,
|
||||
stdout: result.stdout ?? "",
|
||||
stderr: result.stderr ?? "",
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function runCommandChecked(command, args = [], options = {}) {
|
||||
const result = runCommand(command, args, options);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(formatCommandFailure(result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function binaryAvailable(command, versionArgs = ["--version"], options = {}) {
|
||||
const result = runCommand(command, versionArgs, options);
|
||||
if (result.error && /** @type {NodeJS.ErrnoException} */ (result.error).code === "ENOENT") {
|
||||
return { available: false, detail: "not found" };
|
||||
}
|
||||
if (result.error) {
|
||||
return { available: false, detail: result.error.message };
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`;
|
||||
return { available: false, detail };
|
||||
}
|
||||
return { available: true, detail: result.stdout.trim() || result.stderr.trim() || "ok" };
|
||||
}
|
||||
|
||||
function looksLikeMissingProcessMessage(text) {
|
||||
return /not found|no running instance|cannot find|does not exist|no such process/i.test(text);
|
||||
}
|
||||
|
||||
export function terminateProcessTree(pid, options = {}) {
|
||||
if (!Number.isFinite(pid)) {
|
||||
return { attempted: false, delivered: false, method: null };
|
||||
}
|
||||
|
||||
const platform = options.platform ?? process.platform;
|
||||
const runCommandImpl = options.runCommandImpl ?? runCommand;
|
||||
const killImpl = options.killImpl ?? process.kill.bind(process);
|
||||
|
||||
if (platform === "win32") {
|
||||
const result = runCommandImpl("taskkill", ["/PID", String(pid), "/T", "/F"], {
|
||||
cwd: options.cwd,
|
||||
env: options.env
|
||||
});
|
||||
|
||||
if (!result.error && result.status === 0) {
|
||||
return { attempted: true, delivered: true, method: "taskkill", result };
|
||||
}
|
||||
|
||||
const combinedOutput = `${result.stderr}\n${result.stdout}`.trim();
|
||||
if (!result.error && looksLikeMissingProcessMessage(combinedOutput)) {
|
||||
return { attempted: true, delivered: false, method: "taskkill", result };
|
||||
}
|
||||
|
||||
if (result.error?.code === "ENOENT") {
|
||||
try {
|
||||
killImpl(pid);
|
||||
return { attempted: true, delivered: true, method: "kill" };
|
||||
} catch (error) {
|
||||
if (error?.code === "ESRCH") {
|
||||
return { attempted: true, delivered: false, method: "kill" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
throw new Error(formatCommandFailure(result));
|
||||
}
|
||||
|
||||
try {
|
||||
killImpl(-pid, "SIGTERM");
|
||||
return { attempted: true, delivered: true, method: "process-group" };
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
try {
|
||||
killImpl(pid, "SIGTERM");
|
||||
return { attempted: true, delivered: true, method: "process" };
|
||||
} catch (innerError) {
|
||||
if (innerError?.code === "ESRCH") {
|
||||
return { attempted: true, delivered: false, method: "process" };
|
||||
}
|
||||
throw innerError;
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted: true, delivered: false, method: "process-group" };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCommandFailure(result) {
|
||||
const parts = [`${result.command} ${result.args.join(" ")}`.trim()];
|
||||
if (result.signal) {
|
||||
parts.push(`signal=${result.signal}`);
|
||||
} else {
|
||||
parts.push(`exit=${result.status}`);
|
||||
}
|
||||
const stderr = (result.stderr || "").trim();
|
||||
const stdout = (result.stdout || "").trim();
|
||||
if (stderr) {
|
||||
parts.push(stderr);
|
||||
} else if (stdout) {
|
||||
parts.push(stdout);
|
||||
}
|
||||
return parts.join(": ");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function loadPromptTemplate(rootDir, name) {
|
||||
const promptPath = path.join(rootDir, "prompts", `${name}.md`);
|
||||
return fs.readFileSync(promptPath, "utf8");
|
||||
}
|
||||
|
||||
export function interpolateTemplate(template, variables) {
|
||||
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => {
|
||||
return Object.prototype.hasOwnProperty.call(variables, key) ? variables[key] : "";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
function severityRank(severity) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return 0;
|
||||
case "high":
|
||||
return 1;
|
||||
case "medium":
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLineRange(finding) {
|
||||
if (!finding.line_start) {
|
||||
return "";
|
||||
}
|
||||
if (!finding.line_end || finding.line_end === finding.line_start) {
|
||||
return `:${finding.line_start}`;
|
||||
}
|
||||
return `:${finding.line_start}-${finding.line_end}`;
|
||||
}
|
||||
|
||||
function validateReviewResultShape(data) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return "Expected a top-level JSON object.";
|
||||
}
|
||||
if (typeof data.verdict !== "string" || !data.verdict.trim()) {
|
||||
return "Missing string `verdict`.";
|
||||
}
|
||||
if (typeof data.summary !== "string" || !data.summary.trim()) {
|
||||
return "Missing string `summary`.";
|
||||
}
|
||||
if (!Array.isArray(data.findings)) {
|
||||
return "Missing array `findings`.";
|
||||
}
|
||||
if (!Array.isArray(data.next_steps)) {
|
||||
return "Missing array `next_steps`.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeReviewFinding(finding, index) {
|
||||
const source = finding && typeof finding === "object" && !Array.isArray(finding) ? finding : {};
|
||||
const lineStart = Number.isInteger(source.line_start) && source.line_start > 0 ? source.line_start : null;
|
||||
const lineEnd =
|
||||
Number.isInteger(source.line_end) && source.line_end > 0 && (!lineStart || source.line_end >= lineStart)
|
||||
? source.line_end
|
||||
: lineStart;
|
||||
|
||||
return {
|
||||
severity: typeof source.severity === "string" && source.severity.trim() ? source.severity.trim() : "low",
|
||||
title: typeof source.title === "string" && source.title.trim() ? source.title.trim() : `Finding ${index + 1}`,
|
||||
body: typeof source.body === "string" && source.body.trim() ? source.body.trim() : "No details provided.",
|
||||
file: typeof source.file === "string" && source.file.trim() ? source.file.trim() : "unknown",
|
||||
line_start: lineStart,
|
||||
line_end: lineEnd,
|
||||
recommendation: typeof source.recommendation === "string" ? source.recommendation.trim() : ""
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReviewResultData(data) {
|
||||
return {
|
||||
verdict: data.verdict.trim(),
|
||||
summary: data.summary.trim(),
|
||||
findings: data.findings.map((finding, index) => normalizeReviewFinding(finding, index)),
|
||||
next_steps: data.next_steps
|
||||
.filter((step) => typeof step === "string" && step.trim())
|
||||
.map((step) => step.trim())
|
||||
};
|
||||
}
|
||||
|
||||
function isStructuredReviewStoredResult(storedJob) {
|
||||
const result = storedJob?.result;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Object.prototype.hasOwnProperty.call(result, "result") ||
|
||||
Object.prototype.hasOwnProperty.call(result, "parseError")
|
||||
);
|
||||
}
|
||||
|
||||
function formatJobLine(job) {
|
||||
const parts = [job.id, `${job.status || "unknown"}`];
|
||||
if (job.kindLabel) {
|
||||
parts.push(job.kindLabel);
|
||||
}
|
||||
if (job.title) {
|
||||
parts.push(job.title);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
function escapeMarkdownCell(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/\r?\n/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function formatCodexResumeCommand(job) {
|
||||
if (!job?.threadId) {
|
||||
return null;
|
||||
}
|
||||
return `codex resume ${job.threadId}`;
|
||||
}
|
||||
|
||||
function appendActiveJobsTable(lines, jobs) {
|
||||
lines.push("Active jobs:");
|
||||
lines.push("| Job | Kind | Status | Phase | Elapsed | Codex Session ID | Summary | Actions |");
|
||||
lines.push("| --- | --- | --- | --- | --- | --- | --- | --- |");
|
||||
for (const job of jobs) {
|
||||
const actions = [`/codex:status ${job.id}`];
|
||||
if (job.status === "queued" || job.status === "running") {
|
||||
actions.push(`/codex:cancel ${job.id}`);
|
||||
}
|
||||
lines.push(
|
||||
`| ${escapeMarkdownCell(job.id)} | ${escapeMarkdownCell(job.kindLabel)} | ${escapeMarkdownCell(job.status)} | ${escapeMarkdownCell(job.phase ?? "")} | ${escapeMarkdownCell(job.elapsed ?? "")} | ${escapeMarkdownCell(job.threadId ?? "")} | ${escapeMarkdownCell(job.summary ?? "")} | ${actions.map((action) => `\`${action}\``).join("<br>")} |`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pushJobDetails(lines, job, options = {}) {
|
||||
lines.push(`- ${formatJobLine(job)}`);
|
||||
if (job.summary) {
|
||||
lines.push(` Summary: ${job.summary}`);
|
||||
}
|
||||
if (job.phase) {
|
||||
lines.push(` Phase: ${job.phase}`);
|
||||
}
|
||||
if (options.showElapsed && job.elapsed) {
|
||||
lines.push(` Elapsed: ${job.elapsed}`);
|
||||
}
|
||||
if (options.showDuration && job.duration) {
|
||||
lines.push(` Duration: ${job.duration}`);
|
||||
}
|
||||
if (job.threadId) {
|
||||
lines.push(` Codex session ID: ${job.threadId}`);
|
||||
}
|
||||
const resumeCommand = formatCodexResumeCommand(job);
|
||||
if (resumeCommand) {
|
||||
lines.push(` Resume in Codex: ${resumeCommand}`);
|
||||
}
|
||||
if (job.logFile && options.showLog) {
|
||||
lines.push(` Log: ${job.logFile}`);
|
||||
}
|
||||
if ((job.status === "queued" || job.status === "running") && options.showCancelHint) {
|
||||
lines.push(` Cancel: /codex:cancel ${job.id}`);
|
||||
}
|
||||
if (job.status !== "queued" && job.status !== "running" && options.showResultHint) {
|
||||
lines.push(` Result: /codex:result ${job.id}`);
|
||||
}
|
||||
if (job.status !== "queued" && job.status !== "running" && job.jobClass === "task" && job.write && options.showReviewHint) {
|
||||
lines.push(" Review changes: /codex:review --wait");
|
||||
lines.push(" Stricter review: /codex:adversarial-review --wait");
|
||||
}
|
||||
if (job.progressPreview?.length) {
|
||||
lines.push(" Progress:");
|
||||
for (const line of job.progressPreview) {
|
||||
lines.push(` ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendReasoningSection(lines, reasoningSummary) {
|
||||
if (!Array.isArray(reasoningSummary) || reasoningSummary.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.push("", "Reasoning:");
|
||||
for (const section of reasoningSummary) {
|
||||
lines.push(`- ${section}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderSetupReport(report) {
|
||||
const lines = [
|
||||
"# Codex Setup",
|
||||
"",
|
||||
`Status: ${report.ready ? "ready" : "needs attention"}`,
|
||||
"",
|
||||
"Checks:",
|
||||
`- node: ${report.node.detail}`,
|
||||
`- npm: ${report.npm.detail}`,
|
||||
`- codex: ${report.codex.detail}`,
|
||||
`- auth: ${report.auth.detail}`,
|
||||
`- session runtime: ${report.sessionRuntime.label}`,
|
||||
`- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`,
|
||||
""
|
||||
];
|
||||
|
||||
if (report.actionsTaken.length > 0) {
|
||||
lines.push("Actions taken:");
|
||||
for (const action of report.actionsTaken) {
|
||||
lines.push(`- ${action}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.nextSteps.length > 0) {
|
||||
lines.push("Next steps:");
|
||||
for (const step of report.nextSteps) {
|
||||
lines.push(`- ${step}`);
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderReviewResult(parsedResult, meta) {
|
||||
if (!parsedResult.parsed) {
|
||||
const lines = [
|
||||
`# Codex ${meta.reviewLabel}`,
|
||||
"",
|
||||
"Codex did not return valid structured JSON.",
|
||||
"",
|
||||
`- Parse error: ${parsedResult.parseError}`
|
||||
];
|
||||
|
||||
if (parsedResult.rawOutput) {
|
||||
lines.push("", "Raw final message:", "", "```text", parsedResult.rawOutput, "```");
|
||||
}
|
||||
|
||||
appendReasoningSection(lines, meta.reasoningSummary ?? parsedResult.reasoningSummary);
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
const validationError = validateReviewResultShape(parsedResult.parsed);
|
||||
if (validationError) {
|
||||
const lines = [
|
||||
`# Codex ${meta.reviewLabel}`,
|
||||
"",
|
||||
`Target: ${meta.targetLabel}`,
|
||||
"Codex returned JSON with an unexpected review shape.",
|
||||
"",
|
||||
`- Validation error: ${validationError}`
|
||||
];
|
||||
|
||||
if (parsedResult.rawOutput) {
|
||||
lines.push("", "Raw final message:", "", "```text", parsedResult.rawOutput, "```");
|
||||
}
|
||||
|
||||
appendReasoningSection(lines, meta.reasoningSummary ?? parsedResult.reasoningSummary);
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
const data = normalizeReviewResultData(parsedResult.parsed);
|
||||
const findings = [...data.findings].sort((left, right) => severityRank(left.severity) - severityRank(right.severity));
|
||||
const lines = [
|
||||
`# Codex ${meta.reviewLabel}`,
|
||||
"",
|
||||
`Target: ${meta.targetLabel}`,
|
||||
`Verdict: ${data.verdict}`,
|
||||
"",
|
||||
data.summary,
|
||||
""
|
||||
];
|
||||
|
||||
if (findings.length === 0) {
|
||||
lines.push("No material findings.");
|
||||
} else {
|
||||
lines.push("Findings:");
|
||||
for (const finding of findings) {
|
||||
const lineSuffix = formatLineRange(finding);
|
||||
lines.push(`- [${finding.severity}] ${finding.title} (${finding.file}${lineSuffix})`);
|
||||
lines.push(` ${finding.body}`);
|
||||
if (finding.recommendation) {
|
||||
lines.push(` Recommendation: ${finding.recommendation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.next_steps.length > 0) {
|
||||
lines.push("", "Next steps:");
|
||||
for (const step of data.next_steps) {
|
||||
lines.push(`- ${step}`);
|
||||
}
|
||||
}
|
||||
|
||||
appendReasoningSection(lines, meta.reasoningSummary);
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderNativeReviewResult(result, meta) {
|
||||
const stdout = result.stdout.trim();
|
||||
const stderr = result.stderr.trim();
|
||||
const lines = [
|
||||
`# Codex ${meta.reviewLabel}`,
|
||||
"",
|
||||
`Target: ${meta.targetLabel}`,
|
||||
""
|
||||
];
|
||||
|
||||
if (stdout) {
|
||||
lines.push(stdout);
|
||||
} else if (result.status === 0) {
|
||||
lines.push("Codex review completed without any stdout output.");
|
||||
} else {
|
||||
lines.push("Codex review failed.");
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
lines.push("", "stderr:", "", "```text", stderr, "```");
|
||||
}
|
||||
|
||||
appendReasoningSection(lines, meta.reasoningSummary);
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderTaskResult(parsedResult, meta) {
|
||||
const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : "";
|
||||
if (rawOutput) {
|
||||
return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`;
|
||||
}
|
||||
|
||||
const message = String(parsedResult?.failureMessage ?? "").trim() || "Codex did not return a final message.";
|
||||
return `${message}\n`;
|
||||
}
|
||||
|
||||
export function renderStatusReport(report) {
|
||||
const lines = [
|
||||
"# Codex Status",
|
||||
"",
|
||||
`Session runtime: ${report.sessionRuntime.label}`,
|
||||
`Review gate: ${report.config.stopReviewGate ? "enabled" : "disabled"}`,
|
||||
""
|
||||
];
|
||||
|
||||
if (report.running.length > 0) {
|
||||
appendActiveJobsTable(lines, report.running);
|
||||
lines.push("");
|
||||
lines.push("Live details:");
|
||||
for (const job of report.running) {
|
||||
pushJobDetails(lines, job, {
|
||||
showElapsed: true,
|
||||
showLog: true
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.latestFinished) {
|
||||
lines.push("Latest finished:");
|
||||
pushJobDetails(lines, report.latestFinished, {
|
||||
showDuration: true,
|
||||
showLog: report.latestFinished.status === "failed"
|
||||
});
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (report.recent.length > 0) {
|
||||
lines.push("Recent jobs:");
|
||||
for (const job of report.recent) {
|
||||
pushJobDetails(lines, job, {
|
||||
showDuration: true,
|
||||
showLog: job.status === "failed"
|
||||
});
|
||||
}
|
||||
lines.push("");
|
||||
} else if (report.running.length === 0 && !report.latestFinished) {
|
||||
lines.push("No jobs recorded yet.", "");
|
||||
}
|
||||
|
||||
if (report.needsReview) {
|
||||
lines.push("The stop-time review gate is enabled.");
|
||||
lines.push("Ending the session will trigger a fresh Codex adversarial review and block if it finds issues.");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderJobStatusReport(job) {
|
||||
const lines = ["# Codex Job Status", ""];
|
||||
pushJobDetails(lines, job, {
|
||||
showElapsed: job.status === "queued" || job.status === "running",
|
||||
showDuration: job.status !== "queued" && job.status !== "running",
|
||||
showLog: true,
|
||||
showCancelHint: true,
|
||||
showResultHint: true,
|
||||
showReviewHint: true
|
||||
});
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderStoredJobResult(job, storedJob) {
|
||||
const threadId = storedJob?.threadId ?? job.threadId ?? null;
|
||||
const resumeCommand = threadId ? `codex resume ${threadId}` : null;
|
||||
if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) {
|
||||
const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`;
|
||||
if (!threadId) {
|
||||
return output;
|
||||
}
|
||||
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
|
||||
}
|
||||
|
||||
const rawOutput =
|
||||
(typeof storedJob?.result?.rawOutput === "string" && storedJob.result.rawOutput) ||
|
||||
(typeof storedJob?.result?.codex?.stdout === "string" && storedJob.result.codex.stdout) ||
|
||||
"";
|
||||
if (rawOutput) {
|
||||
const output = rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`;
|
||||
if (!threadId) {
|
||||
return output;
|
||||
}
|
||||
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
|
||||
}
|
||||
|
||||
if (storedJob?.rendered) {
|
||||
const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`;
|
||||
if (!threadId) {
|
||||
return output;
|
||||
}
|
||||
return `${output}\nCodex session ID: ${threadId}\nResume in Codex: ${resumeCommand}\n`;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`# ${job.title ?? "Codex Result"}`,
|
||||
"",
|
||||
`Job: ${job.id}`,
|
||||
`Status: ${job.status}`
|
||||
];
|
||||
|
||||
if (threadId) {
|
||||
lines.push(`Codex session ID: ${threadId}`);
|
||||
lines.push(`Resume in Codex: ${resumeCommand}`);
|
||||
}
|
||||
|
||||
if (job.summary) {
|
||||
lines.push(`Summary: ${job.summary}`);
|
||||
}
|
||||
|
||||
if (job.errorMessage) {
|
||||
lines.push("", job.errorMessage);
|
||||
} else if (storedJob?.errorMessage) {
|
||||
lines.push("", storedJob.errorMessage);
|
||||
} else {
|
||||
lines.push("", "No captured result payload was stored for this job.");
|
||||
}
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
|
||||
export function renderCancelReport(job) {
|
||||
const lines = [
|
||||
"# Codex Cancel",
|
||||
"",
|
||||
`Cancelled ${job.id}.`,
|
||||
""
|
||||
];
|
||||
|
||||
if (job.title) {
|
||||
lines.push(`- Title: ${job.title}`);
|
||||
}
|
||||
if (job.summary) {
|
||||
lines.push(`- Summary: ${job.summary}`);
|
||||
}
|
||||
lines.push("- Check `/codex:status` for the updated queue.");
|
||||
|
||||
return `${lines.join("\n").trimEnd()}\n`;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveWorkspaceRoot } from "./workspace.mjs";
|
||||
|
||||
const STATE_VERSION = 1;
|
||||
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
|
||||
const FALLBACK_STATE_ROOT_DIR = path.join(os.tmpdir(), "codex-companion");
|
||||
const STATE_FILE_NAME = "state.json";
|
||||
const JOBS_DIR_NAME = "jobs";
|
||||
const MAX_JOBS = 50;
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function defaultState() {
|
||||
return {
|
||||
version: STATE_VERSION,
|
||||
config: {
|
||||
stopReviewGate: false
|
||||
},
|
||||
jobs: []
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStateDir(cwd) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
let canonicalWorkspaceRoot = workspaceRoot;
|
||||
try {
|
||||
canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot);
|
||||
} catch {
|
||||
canonicalWorkspaceRoot = workspaceRoot;
|
||||
}
|
||||
|
||||
const slugSource = path.basename(workspaceRoot) || "workspace";
|
||||
const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace";
|
||||
const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16);
|
||||
const pluginDataDir = process.env[PLUGIN_DATA_ENV];
|
||||
const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR;
|
||||
return path.join(stateRoot, `${slug}-${hash}`);
|
||||
}
|
||||
|
||||
export function resolveStateFile(cwd) {
|
||||
return path.join(resolveStateDir(cwd), STATE_FILE_NAME);
|
||||
}
|
||||
|
||||
export function resolveJobsDir(cwd) {
|
||||
return path.join(resolveStateDir(cwd), JOBS_DIR_NAME);
|
||||
}
|
||||
|
||||
export function ensureStateDir(cwd) {
|
||||
fs.mkdirSync(resolveJobsDir(cwd), { recursive: true });
|
||||
}
|
||||
|
||||
export function loadState(cwd) {
|
||||
const stateFile = resolveStateFile(cwd);
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
return defaultState();
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
return {
|
||||
...defaultState(),
|
||||
...parsed,
|
||||
config: {
|
||||
...defaultState().config,
|
||||
...(parsed.config ?? {})
|
||||
},
|
||||
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : []
|
||||
};
|
||||
} catch {
|
||||
return defaultState();
|
||||
}
|
||||
}
|
||||
|
||||
function pruneJobs(jobs) {
|
||||
return [...jobs]
|
||||
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
|
||||
.slice(0, MAX_JOBS);
|
||||
}
|
||||
|
||||
function removeFileIfExists(filePath) {
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveState(cwd, state) {
|
||||
const previousJobs = loadState(cwd).jobs;
|
||||
ensureStateDir(cwd);
|
||||
const nextJobs = pruneJobs(state.jobs ?? []);
|
||||
const nextState = {
|
||||
version: STATE_VERSION,
|
||||
config: {
|
||||
...defaultState().config,
|
||||
...(state.config ?? {})
|
||||
},
|
||||
jobs: nextJobs
|
||||
};
|
||||
|
||||
const retainedIds = new Set(nextJobs.map((job) => job.id));
|
||||
for (const job of previousJobs) {
|
||||
if (retainedIds.has(job.id)) {
|
||||
continue;
|
||||
}
|
||||
removeJobFile(resolveJobFile(cwd, job.id));
|
||||
removeFileIfExists(job.logFile);
|
||||
}
|
||||
|
||||
fs.writeFileSync(resolveStateFile(cwd), `${JSON.stringify(nextState, null, 2)}\n`, "utf8");
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function updateState(cwd, mutate) {
|
||||
const state = loadState(cwd);
|
||||
mutate(state);
|
||||
return saveState(cwd, state);
|
||||
}
|
||||
|
||||
export function generateJobId(prefix = "job") {
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
return `${prefix}-${Date.now().toString(36)}-${random}`;
|
||||
}
|
||||
|
||||
export function upsertJob(cwd, jobPatch) {
|
||||
return updateState(cwd, (state) => {
|
||||
const timestamp = nowIso();
|
||||
const existingIndex = state.jobs.findIndex((job) => job.id === jobPatch.id);
|
||||
if (existingIndex === -1) {
|
||||
state.jobs.unshift({
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
...jobPatch
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.jobs[existingIndex] = {
|
||||
...state.jobs[existingIndex],
|
||||
...jobPatch,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function listJobs(cwd) {
|
||||
return loadState(cwd).jobs;
|
||||
}
|
||||
|
||||
export function setConfig(cwd, key, value) {
|
||||
return updateState(cwd, (state) => {
|
||||
state.config = {
|
||||
...state.config,
|
||||
[key]: value
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getConfig(cwd) {
|
||||
return loadState(cwd).config;
|
||||
}
|
||||
|
||||
export function writeJobFile(cwd, jobId, payload) {
|
||||
ensureStateDir(cwd);
|
||||
const jobFile = resolveJobFile(cwd, jobId);
|
||||
fs.writeFileSync(jobFile, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
||||
return jobFile;
|
||||
}
|
||||
|
||||
export function readJobFile(jobFile) {
|
||||
return JSON.parse(fs.readFileSync(jobFile, "utf8"));
|
||||
}
|
||||
|
||||
function removeJobFile(jobFile) {
|
||||
if (fs.existsSync(jobFile)) {
|
||||
fs.unlinkSync(jobFile);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveJobLogFile(cwd, jobId) {
|
||||
ensureStateDir(cwd);
|
||||
return path.join(resolveJobsDir(cwd), `${jobId}.log`);
|
||||
}
|
||||
|
||||
export function resolveJobFile(cwd, jobId) {
|
||||
ensureStateDir(cwd);
|
||||
return path.join(resolveJobsDir(cwd), `${jobId}.json`);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
|
||||
import { readJobFile, resolveJobFile, resolveJobLogFile, upsertJob, writeJobFile } from "./state.mjs";
|
||||
|
||||
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
|
||||
|
||||
export function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function normalizeProgressEvent(value) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return {
|
||||
message: String(value.message ?? "").trim(),
|
||||
phase: typeof value.phase === "string" && value.phase.trim() ? value.phase.trim() : null,
|
||||
threadId: typeof value.threadId === "string" && value.threadId.trim() ? value.threadId.trim() : null,
|
||||
turnId: typeof value.turnId === "string" && value.turnId.trim() ? value.turnId.trim() : null,
|
||||
stderrMessage: value.stderrMessage == null ? null : String(value.stderrMessage).trim(),
|
||||
logTitle: typeof value.logTitle === "string" && value.logTitle.trim() ? value.logTitle.trim() : null,
|
||||
logBody: value.logBody == null ? null : String(value.logBody).trimEnd()
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: String(value ?? "").trim(),
|
||||
phase: null,
|
||||
threadId: null,
|
||||
turnId: null,
|
||||
stderrMessage: String(value ?? "").trim(),
|
||||
logTitle: null,
|
||||
logBody: null
|
||||
};
|
||||
}
|
||||
|
||||
export function appendLogLine(logFile, message) {
|
||||
const normalized = String(message ?? "").trim();
|
||||
if (!logFile || !normalized) {
|
||||
return;
|
||||
}
|
||||
fs.appendFileSync(logFile, `[${nowIso()}] ${normalized}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function appendLogBlock(logFile, title, body) {
|
||||
if (!logFile || !body) {
|
||||
return;
|
||||
}
|
||||
fs.appendFileSync(logFile, `\n[${nowIso()}] ${title}\n${String(body).trimEnd()}\n`, "utf8");
|
||||
}
|
||||
|
||||
export function createJobLogFile(workspaceRoot, jobId, title) {
|
||||
const logFile = resolveJobLogFile(workspaceRoot, jobId);
|
||||
fs.writeFileSync(logFile, "", "utf8");
|
||||
if (title) {
|
||||
appendLogLine(logFile, `Starting ${title}.`);
|
||||
}
|
||||
return logFile;
|
||||
}
|
||||
|
||||
export function createJobRecord(base, options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const sessionId = env[options.sessionIdEnv ?? SESSION_ID_ENV];
|
||||
return {
|
||||
...base,
|
||||
createdAt: nowIso(),
|
||||
...(sessionId ? { sessionId } : {})
|
||||
};
|
||||
}
|
||||
|
||||
export function createJobProgressUpdater(workspaceRoot, jobId) {
|
||||
let lastPhase = null;
|
||||
let lastThreadId = null;
|
||||
let lastTurnId = null;
|
||||
|
||||
return (event) => {
|
||||
const normalized = normalizeProgressEvent(event);
|
||||
const patch = { id: jobId };
|
||||
let changed = false;
|
||||
|
||||
if (normalized.phase && normalized.phase !== lastPhase) {
|
||||
lastPhase = normalized.phase;
|
||||
patch.phase = normalized.phase;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (normalized.threadId && normalized.threadId !== lastThreadId) {
|
||||
lastThreadId = normalized.threadId;
|
||||
patch.threadId = normalized.threadId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (normalized.turnId && normalized.turnId !== lastTurnId) {
|
||||
lastTurnId = normalized.turnId;
|
||||
patch.turnId = normalized.turnId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
upsertJob(workspaceRoot, patch);
|
||||
|
||||
const jobFile = resolveJobFile(workspaceRoot, jobId);
|
||||
if (!fs.existsSync(jobFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedJob = readJobFile(jobFile);
|
||||
writeJobFile(workspaceRoot, jobId, {
|
||||
...storedJob,
|
||||
...patch
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function createProgressReporter({ stderr = false, logFile = null, onEvent = null } = {}) {
|
||||
if (!stderr && !logFile && !onEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (eventOrMessage) => {
|
||||
const event = normalizeProgressEvent(eventOrMessage);
|
||||
const stderrMessage = event.stderrMessage ?? event.message;
|
||||
if (stderr && stderrMessage) {
|
||||
process.stderr.write(`[codex] ${stderrMessage}\n`);
|
||||
}
|
||||
appendLogLine(logFile, event.message);
|
||||
appendLogBlock(logFile, event.logTitle, event.logBody);
|
||||
onEvent?.(event);
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredJobOrNull(workspaceRoot, jobId) {
|
||||
const jobFile = resolveJobFile(workspaceRoot, jobId);
|
||||
if (!fs.existsSync(jobFile)) {
|
||||
return null;
|
||||
}
|
||||
return readJobFile(jobFile);
|
||||
}
|
||||
|
||||
export async function runTrackedJob(job, runner, options = {}) {
|
||||
const runningRecord = {
|
||||
...job,
|
||||
status: "running",
|
||||
startedAt: nowIso(),
|
||||
phase: "starting",
|
||||
pid: process.pid,
|
||||
logFile: options.logFile ?? job.logFile ?? null
|
||||
};
|
||||
writeJobFile(job.workspaceRoot, job.id, runningRecord);
|
||||
upsertJob(job.workspaceRoot, runningRecord);
|
||||
|
||||
try {
|
||||
const execution = await runner();
|
||||
const completionStatus = execution.exitStatus === 0 ? "completed" : "failed";
|
||||
const completedAt = nowIso();
|
||||
writeJobFile(job.workspaceRoot, job.id, {
|
||||
...runningRecord,
|
||||
status: completionStatus,
|
||||
threadId: execution.threadId ?? null,
|
||||
turnId: execution.turnId ?? null,
|
||||
pid: null,
|
||||
phase: completionStatus === "completed" ? "done" : "failed",
|
||||
completedAt,
|
||||
result: execution.payload,
|
||||
rendered: execution.rendered
|
||||
});
|
||||
upsertJob(job.workspaceRoot, {
|
||||
id: job.id,
|
||||
status: completionStatus,
|
||||
threadId: execution.threadId ?? null,
|
||||
turnId: execution.turnId ?? null,
|
||||
summary: execution.summary,
|
||||
phase: completionStatus === "completed" ? "done" : "failed",
|
||||
pid: null,
|
||||
completedAt
|
||||
});
|
||||
appendLogBlock(options.logFile ?? job.logFile ?? null, "Final output", execution.rendered);
|
||||
return execution;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const existing = readStoredJobOrNull(job.workspaceRoot, job.id) ?? runningRecord;
|
||||
const completedAt = nowIso();
|
||||
writeJobFile(job.workspaceRoot, job.id, {
|
||||
...existing,
|
||||
status: "failed",
|
||||
phase: "failed",
|
||||
errorMessage,
|
||||
pid: null,
|
||||
completedAt,
|
||||
logFile: options.logFile ?? job.logFile ?? existing.logFile ?? null
|
||||
});
|
||||
upsertJob(job.workspaceRoot, {
|
||||
id: job.id,
|
||||
status: "failed",
|
||||
phase: "failed",
|
||||
pid: null,
|
||||
errorMessage,
|
||||
completedAt
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ensureGitRepository } from "./git.mjs";
|
||||
|
||||
export function resolveWorkspaceRoot(cwd) {
|
||||
try {
|
||||
return ensureGitRepository(cwd);
|
||||
} catch {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
|
||||
import { terminateProcessTree } from "./lib/process.mjs";
|
||||
import { BROKER_ENDPOINT_ENV } from "./lib/app-server.mjs";
|
||||
import {
|
||||
clearBrokerSession,
|
||||
LOG_FILE_ENV,
|
||||
loadBrokerSession,
|
||||
PID_FILE_ENV,
|
||||
sendBrokerShutdown,
|
||||
teardownBrokerSession
|
||||
} from "./lib/broker-lifecycle.mjs";
|
||||
import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
|
||||
import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs";
|
||||
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
||||
|
||||
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
|
||||
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
|
||||
|
||||
function readHookInput() {
|
||||
const raw = fs.readFileSync(0, "utf8").trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function shellEscape(value) {
|
||||
return `'${String(value).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function appendEnvVar(name, value) {
|
||||
if (!process.env.CLAUDE_ENV_FILE || value == null || value === "") {
|
||||
return;
|
||||
}
|
||||
fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `export ${name}=${shellEscape(value)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function cleanupSessionJobs(cwd, sessionId) {
|
||||
if (!cwd || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const stateFile = resolveStateFile(workspaceRoot);
|
||||
if (!fs.existsSync(stateFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = loadState(workspaceRoot);
|
||||
const removedJobs = state.jobs.filter((job) => job.sessionId === sessionId);
|
||||
if (removedJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const job of removedJobs) {
|
||||
const stillRunning = job.status === "queued" || job.status === "running";
|
||||
if (!stillRunning) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
terminateProcessTree(job.pid ?? Number.NaN);
|
||||
} catch {
|
||||
// Ignore teardown failures during session shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
saveState(workspaceRoot, {
|
||||
...state,
|
||||
jobs: state.jobs.filter((job) => job.sessionId !== sessionId)
|
||||
});
|
||||
}
|
||||
|
||||
function handleSessionStart(input) {
|
||||
appendEnvVar(SESSION_ID_ENV, input.session_id);
|
||||
appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path);
|
||||
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
|
||||
}
|
||||
|
||||
async function handleSessionEnd(input) {
|
||||
const cwd = input.cwd || process.cwd();
|
||||
const brokerSession =
|
||||
loadBrokerSession(cwd) ??
|
||||
(process.env[BROKER_ENDPOINT_ENV]
|
||||
? {
|
||||
endpoint: process.env[BROKER_ENDPOINT_ENV],
|
||||
pidFile: process.env[PID_FILE_ENV] ?? null,
|
||||
logFile: process.env[LOG_FILE_ENV] ?? null
|
||||
}
|
||||
: null);
|
||||
const brokerEndpoint = brokerSession?.endpoint ?? null;
|
||||
const pidFile = brokerSession?.pidFile ?? null;
|
||||
const logFile = brokerSession?.logFile ?? null;
|
||||
const sessionDir = brokerSession?.sessionDir ?? null;
|
||||
const pid = brokerSession?.pid ?? null;
|
||||
|
||||
if (brokerEndpoint) {
|
||||
await sendBrokerShutdown(brokerEndpoint);
|
||||
}
|
||||
|
||||
cleanupSessionJobs(cwd, input.session_id || process.env[SESSION_ID_ENV]);
|
||||
teardownBrokerSession({
|
||||
endpoint: brokerEndpoint,
|
||||
pidFile,
|
||||
logFile,
|
||||
sessionDir,
|
||||
pid,
|
||||
killProcess: terminateProcessTree
|
||||
});
|
||||
clearBrokerSession(cwd);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const input = readHookInput();
|
||||
const eventName = process.argv[2] ?? input.hook_event_name ?? "";
|
||||
|
||||
if (eventName === "SessionStart") {
|
||||
handleSessionStart(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName === "SessionEnd") {
|
||||
await handleSessionEnd(input);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import process from "node:process";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getCodexAvailability } from "./lib/codex.mjs";
|
||||
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
|
||||
import { getConfig, listJobs } from "./lib/state.mjs";
|
||||
import { sortJobsNewestFirst } from "./lib/job-control.mjs";
|
||||
import { SESSION_ID_ENV } from "./lib/tracked-jobs.mjs";
|
||||
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
||||
|
||||
const STOP_REVIEW_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..");
|
||||
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
|
||||
|
||||
function readHookInput() {
|
||||
const raw = fs.readFileSync(0, "utf8").trim();
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function emitDecision(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
function logNote(message) {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
|
||||
function filterJobsForCurrentSession(jobs, input = {}) {
|
||||
const sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
|
||||
if (!sessionId) {
|
||||
return jobs;
|
||||
}
|
||||
return jobs.filter((job) => job.sessionId === sessionId);
|
||||
}
|
||||
|
||||
function buildStopReviewPrompt(input = {}) {
|
||||
const lastAssistantMessage = String(input.last_assistant_message ?? "").trim();
|
||||
const template = loadPromptTemplate(ROOT_DIR, "stop-review-gate");
|
||||
const claudeResponseBlock = lastAssistantMessage
|
||||
? ["Previous Claude response:", lastAssistantMessage].join("\n")
|
||||
: "";
|
||||
return interpolateTemplate(template, {
|
||||
CLAUDE_RESPONSE_BLOCK: claudeResponseBlock
|
||||
});
|
||||
}
|
||||
|
||||
function buildSetupNote(cwd) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (availability.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = availability.detail ? ` ${availability.detail}.` : "";
|
||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
|
||||
}
|
||||
|
||||
function parseStopReviewOutput(rawOutput) {
|
||||
const text = String(rawOutput ?? "").trim();
|
||||
if (!text) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"The stop-time Codex review task returned no final output. Run /codex:review --wait manually or bypass the gate."
|
||||
};
|
||||
}
|
||||
|
||||
const firstLine = text.split(/\r?\n/, 1)[0].trim();
|
||||
if (firstLine.startsWith("ALLOW:")) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
if (firstLine.startsWith("BLOCK:")) {
|
||||
const reason = firstLine.slice("BLOCK:".length).trim() || text;
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Codex stop-time review found issues that still need fixes before ending the session: ${reason}`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"The stop-time Codex review task returned an unexpected answer. Run /codex:review --wait manually or bypass the gate."
|
||||
};
|
||||
}
|
||||
|
||||
function runStopReview(cwd, input = {}) {
|
||||
const scriptPath = path.join(SCRIPT_DIR, "codex-companion.mjs");
|
||||
const prompt = buildStopReviewPrompt(input);
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
...(input.session_id ? { [SESSION_ID_ENV]: input.session_id } : {})
|
||||
};
|
||||
const result = spawnSync(process.execPath, [scriptPath, "task", "--json", prompt], {
|
||||
cwd,
|
||||
env: childEnv,
|
||||
encoding: "utf8",
|
||||
timeout: STOP_REVIEW_TIMEOUT_MS
|
||||
});
|
||||
|
||||
if (result.error?.code === "ETIMEDOUT") {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"The stop-time Codex review task timed out after 15 minutes. Run /codex:review --wait manually or bypass the gate."
|
||||
};
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const detail = String(result.stderr || result.stdout || "").trim();
|
||||
return {
|
||||
ok: false,
|
||||
reason: detail
|
||||
? `The stop-time Codex review task failed: ${detail}`
|
||||
: "The stop-time Codex review task failed. Run /codex:review --wait manually or bypass the gate."
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(result.stdout);
|
||||
return parseStopReviewOutput(payload?.rawOutput);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"The stop-time Codex review task returned invalid JSON. Run /codex:review --wait manually or bypass the gate."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const input = readHookInput();
|
||||
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const config = getConfig(workspaceRoot);
|
||||
|
||||
const jobs = sortJobsNewestFirst(filterJobsForCurrentSession(listJobs(workspaceRoot), input));
|
||||
const runningJob = jobs.find((job) => job.status === "queued" || job.status === "running");
|
||||
const runningTaskNote = runningJob
|
||||
? `Codex task ${runningJob.id} is still running. Check /codex:status and use /codex:cancel ${runningJob.id} if you want to stop it before ending the session.`
|
||||
: null;
|
||||
|
||||
if (!config.stopReviewGate) {
|
||||
logNote(runningTaskNote);
|
||||
return;
|
||||
}
|
||||
|
||||
const setupNote = buildSetupNote(cwd);
|
||||
if (setupNote) {
|
||||
logNote(setupNote);
|
||||
logNote(runningTaskNote);
|
||||
return;
|
||||
}
|
||||
|
||||
const review = runStopReview(cwd, input);
|
||||
if (!review.ok) {
|
||||
emitDecision({
|
||||
decision: "block",
|
||||
reason: runningTaskNote ? `${runningTaskNote} ${review.reason}` : review.reason
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logNote(runningTaskNote);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user