chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:44 +08:00
commit 93aa9c11ee
64 changed files with 11530 additions and 0 deletions
+75
View File
@@ -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;
+354
View File
@@ -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;
}
}
+128
View File
@@ -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
+40
View File
@@ -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");
}
+347
View File
@@ -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
};
}
+308
View File
@@ -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.");
}
+135
View File
@@ -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(": ");
}
+13
View File
@@ -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] : "";
});
}
+465
View File
@@ -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`;
}
+191
View File
@@ -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`);
}
+204
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { ensureGitRepository } from "./git.mjs";
export function resolveWorkspaceRoot(cwd) {
try {
return ensureGitRepository(cwd);
} catch {
return cwd;
}
}