Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e873d6f40 | |||
| bb38412a67 | |||
| 6a5c2ba53b | |||
| 8e9a38cdfe | |||
| b1156235e0 | |||
| c24afe8404 | |||
| 11a720b7db | |||
| bc8fa661a5 | |||
| d216a5fdea | |||
| 40d213d13f | |||
| 4bd783b7ce | |||
| dd335cbc76 | |||
| 62c351a7bf | |||
| f17e7f8486 | |||
| 594fd1e8da |
@@ -5,13 +5,13 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Codex plugins to use in Claude Code for delegation and code review.",
|
||||
"version": "1.0.2"
|
||||
"version": "1.0.4"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "codex",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"author": {
|
||||
"name": "OpenAI"
|
||||
},
|
||||
|
||||
@@ -259,7 +259,7 @@ If you want to change the default reasoning effort or the default model that get
|
||||
|
||||
```toml
|
||||
model = "gpt-5.4-mini"
|
||||
model_reasoning_effort = "xhigh"
|
||||
model_reasoning_effort = "high"
|
||||
```
|
||||
|
||||
Your configuration will be picked up based on:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.5.0",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openai/codex-plugin-cc",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
@@ -9,6 +9,8 @@
|
||||
"node": ">=18.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"bump-version": "node scripts/bump-version.mjs",
|
||||
"check-version": "node scripts/bump-version.mjs --check",
|
||||
"prebuild": "mkdir -p plugins/codex/.generated/app-server-types && codex app-server generate-ts --out plugins/codex/.generated/app-server-types",
|
||||
"build": "tsc -p tsconfig.app-server.json",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"description": "Use Codex from Claude Code to review code or delegate tasks.",
|
||||
"author": {
|
||||
"name": "OpenAI"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: codex-rescue
|
||||
description: Proactively use when Claude Code is stuck, wants a second implementation or diagnosis pass, needs a deeper root-cause investigation, or should hand a substantial coding task to Codex through the shared runtime
|
||||
model: sonnet
|
||||
tools: Bash
|
||||
skills:
|
||||
- codex-cli-runtime
|
||||
|
||||
@@ -5,4 +5,4 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"`
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent
|
||||
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should investigate, solve, or continue]"
|
||||
context: fork
|
||||
allowed-tools: Bash(node:*), AskUserQuestion
|
||||
allowed-tools: Bash(node:*), AskUserQuestion, Agent
|
||||
---
|
||||
|
||||
Route this request to the `codex:codex-rescue` subagent.
|
||||
Invoke the `codex:codex-rescue` subagent via the `Agent` tool (`subagent_type: "codex:codex-rescue"`), forwarding the raw user request as the prompt.
|
||||
`codex:codex-rescue` is a subagent, not a skill — do not call `Skill(codex:codex-rescue)` (no such skill) or `Skill(codex:rescue)` (that re-enters this command and hangs the session). The command runs inline so the `Agent` tool stays in scope; forked general-purpose subagents do not expose it.
|
||||
The final user-visible response must be Codex's output verbatim.
|
||||
|
||||
Raw user request:
|
||||
|
||||
@@ -5,7 +5,7 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"`
|
||||
|
||||
Present the full command output to the user. Do not summarize or condense it. Preserve all details including:
|
||||
- Job ID and status
|
||||
|
||||
@@ -5,7 +5,7 @@ disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status $ARGUMENTS`
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"`
|
||||
|
||||
If the user did not pass a job ID:
|
||||
- Render the command output as a single Markdown table for the current and past runs in this session.
|
||||
|
||||
@@ -32,6 +32,7 @@ Actively try to disprove the change.
|
||||
Look for violated invariants, missing guards, unhandled failure paths, and assumptions that stop being true under stress.
|
||||
Trace how bad inputs, retries, concurrent actions, or partially completed operations move through the code.
|
||||
If the user supplied a focus area, weight it heavily, but still report any other material issue you can defend.
|
||||
{{REVIEW_COLLECTION_GUIDANCE}}
|
||||
</review_method>
|
||||
|
||||
<finding_bar>
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
buildPersistentTaskThreadName,
|
||||
DEFAULT_CONTINUE_PROMPT,
|
||||
findLatestTaskThread,
|
||||
getCodexAuthStatus,
|
||||
getCodexAvailability,
|
||||
getCodexLoginStatus,
|
||||
getSessionRuntimeStatus,
|
||||
interruptAppServerTurn,
|
||||
parseStructuredOutput,
|
||||
@@ -176,19 +176,19 @@ function firstMeaningfulLine(text, fallback) {
|
||||
return line ?? fallback;
|
||||
}
|
||||
|
||||
function buildSetupReport(cwd, actionsTaken = []) {
|
||||
async function buildSetupReport(cwd, actionsTaken = []) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const nodeStatus = binaryAvailable("node", ["--version"], { cwd });
|
||||
const npmStatus = binaryAvailable("npm", ["--version"], { cwd });
|
||||
const codexStatus = getCodexAvailability(cwd);
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
const authStatus = await getCodexAuthStatus(cwd);
|
||||
const config = getConfig(workspaceRoot);
|
||||
|
||||
const nextSteps = [];
|
||||
if (!codexStatus.available) {
|
||||
nextSteps.push("Install Codex with `npm install -g @openai/codex`.");
|
||||
}
|
||||
if (codexStatus.available && !authStatus.loggedIn) {
|
||||
if (codexStatus.available && !authStatus.loggedIn && authStatus.requiresOpenaiAuth) {
|
||||
nextSteps.push("Run `!codex login`.");
|
||||
nextSteps.push("If browser login is blocked, retry with `!codex login --device-auth` or `!codex login --with-api-key`.");
|
||||
}
|
||||
@@ -202,14 +202,14 @@ function buildSetupReport(cwd, actionsTaken = []) {
|
||||
npm: npmStatus,
|
||||
codex: codexStatus,
|
||||
auth: authStatus,
|
||||
sessionRuntime: getSessionRuntimeStatus(),
|
||||
sessionRuntime: getSessionRuntimeStatus(process.env, workspaceRoot),
|
||||
reviewGateEnabled: Boolean(config.stopReviewGate),
|
||||
actionsTaken,
|
||||
nextSteps
|
||||
};
|
||||
}
|
||||
|
||||
function handleSetup(argv) {
|
||||
async function handleSetup(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd"],
|
||||
booleanOptions: ["json", "enable-review-gate", "disable-review-gate"]
|
||||
@@ -231,7 +231,7 @@ function handleSetup(argv) {
|
||||
actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`);
|
||||
}
|
||||
|
||||
const finalReport = buildSetupReport(cwd, actionsTaken);
|
||||
const finalReport = await buildSetupReport(cwd, actionsTaken);
|
||||
outputResult(options.json ? finalReport : renderSetupReport(finalReport), options.json);
|
||||
}
|
||||
|
||||
@@ -241,18 +241,16 @@ function buildAdversarialReviewPrompt(context, focusText) {
|
||||
REVIEW_KIND: "Adversarial Review",
|
||||
TARGET_LABEL: context.target.label,
|
||||
USER_FOCUS: focusText || "No extra focus provided.",
|
||||
REVIEW_COLLECTION_GUIDANCE: context.collectionGuidance,
|
||||
REVIEW_INPUT: context.content
|
||||
});
|
||||
}
|
||||
|
||||
function ensureCodexReady(cwd) {
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
if (!authStatus.available) {
|
||||
function ensureCodexAvailable(cwd) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
|
||||
}
|
||||
if (!authStatus.loggedIn) {
|
||||
throw new Error("Codex CLI is not authenticated. Run `!codex login` and retry.");
|
||||
}
|
||||
}
|
||||
|
||||
function buildNativeReviewTarget(target) {
|
||||
@@ -290,6 +288,30 @@ function isActiveJobStatus(status) {
|
||||
return status === "queued" || status === "running";
|
||||
}
|
||||
|
||||
function getCurrentClaudeSessionId() {
|
||||
return process.env[SESSION_ID_ENV] ?? null;
|
||||
}
|
||||
|
||||
function filterJobsForCurrentClaudeSession(jobs) {
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
if (!sessionId) {
|
||||
return jobs;
|
||||
}
|
||||
return jobs.filter((job) => job.sessionId === sessionId);
|
||||
}
|
||||
|
||||
function findLatestResumableTaskJob(jobs) {
|
||||
return (
|
||||
jobs.find(
|
||||
(job) =>
|
||||
job.jobClass === "task" &&
|
||||
job.threadId &&
|
||||
job.status !== "queued" &&
|
||||
job.status !== "running"
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
|
||||
const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
|
||||
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
|
||||
@@ -310,22 +332,28 @@ async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
|
||||
|
||||
async function resolveLatestTrackedTaskThread(cwd, options = {}) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId);
|
||||
const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
|
||||
const visibleJobs = filterJobsForCurrentClaudeSession(jobs);
|
||||
const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
|
||||
if (activeTask) {
|
||||
throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`);
|
||||
}
|
||||
|
||||
const trackedTask = jobs.find((job) => job.jobClass === "task" && job.status === "completed" && job.threadId);
|
||||
const trackedTask = findLatestResumableTaskJob(visibleJobs);
|
||||
if (trackedTask) {
|
||||
return { id: trackedTask.threadId };
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findLatestTaskThread(workspaceRoot);
|
||||
}
|
||||
|
||||
async function executeReviewRun(request) {
|
||||
ensureCodexReady(request.cwd);
|
||||
ensureCodexAvailable(request.cwd);
|
||||
ensureGitRepository(request.cwd);
|
||||
|
||||
const target = resolveReviewTarget(request.cwd, {
|
||||
@@ -429,7 +457,7 @@ async function executeReviewRun(request) {
|
||||
|
||||
async function executeTaskRun(request) {
|
||||
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
|
||||
ensureCodexReady(request.cwd);
|
||||
ensureCodexAvailable(request.cwd);
|
||||
|
||||
const taskMetadata = buildTaskRunMetadata({
|
||||
prompt: request.prompt,
|
||||
@@ -728,7 +756,7 @@ async function handleTask(argv) {
|
||||
});
|
||||
|
||||
if (options.background) {
|
||||
ensureCodexReady(cwd);
|
||||
ensureCodexAvailable(cwd);
|
||||
requireTaskRequest(prompt, resumeLast);
|
||||
|
||||
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
|
||||
@@ -862,17 +890,9 @@ function handleTaskResumeCandidate(argv) {
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const workspaceRoot = resolveCommandWorkspace(options);
|
||||
const sessionId = process.env[SESSION_ID_ENV] ?? null;
|
||||
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
|
||||
const candidate =
|
||||
jobs.find(
|
||||
(job) =>
|
||||
job.jobClass === "task" &&
|
||||
job.threadId &&
|
||||
job.status !== "queued" &&
|
||||
job.status !== "running" &&
|
||||
(!sessionId || job.sessionId === sessionId)
|
||||
) ?? null;
|
||||
const sessionId = getCurrentClaudeSessionId();
|
||||
const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
|
||||
const candidate = findLatestResumableTaskJob(jobs);
|
||||
|
||||
const payload = {
|
||||
available: Boolean(candidate),
|
||||
@@ -905,7 +925,7 @@ async function handleCancel(argv) {
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const reference = positionals[0] ?? "";
|
||||
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference);
|
||||
const { workspaceRoot, job } = resolveCancelableJob(cwd, reference, { env: process.env });
|
||||
const existing = readStoredJob(workspaceRoot, job.id) ?? {};
|
||||
const threadId = existing.threadId ?? job.threadId ?? null;
|
||||
const turnId = existing.turnId ?? job.turnId ?? null;
|
||||
@@ -967,7 +987,7 @@ async function main() {
|
||||
|
||||
switch (subcommand) {
|
||||
case "setup":
|
||||
handleSetup(argv);
|
||||
await handleSetup(argv);
|
||||
break;
|
||||
case "review":
|
||||
await handleReview(argv);
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
|
||||
capabilities?: InitializeCapabilities;
|
||||
brokerEndpoint?: string;
|
||||
disableBroker?: boolean;
|
||||
reuseExistingBroker?: boolean;
|
||||
}
|
||||
|
||||
export interface AppServerMethodMap {
|
||||
|
||||
@@ -13,7 +13,7 @@ import process from "node:process";
|
||||
import { spawn } from "node:child_process";
|
||||
import readline from "node:readline";
|
||||
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
|
||||
import { ensureBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { terminateProcessTree } from "./process.mjs";
|
||||
|
||||
const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
|
||||
@@ -188,9 +188,9 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
|
||||
async initialize() {
|
||||
this.proc = spawn("codex", ["app-server"], {
|
||||
cwd: this.cwd,
|
||||
env: this.options.env,
|
||||
env: this.options.env ?? process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
@@ -333,7 +333,10 @@ export class CodexAppServerClient {
|
||||
let brokerEndpoint = null;
|
||||
if (!options.disableBroker) {
|
||||
brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null;
|
||||
if (!brokerEndpoint) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
import { readJsonFile } from "./fs.mjs";
|
||||
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
|
||||
import { loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||
import { binaryAvailable, runCommand } from "./process.mjs";
|
||||
import { binaryAvailable } from "./process.mjs";
|
||||
|
||||
const SERVICE_NAME = "claude_code_codex_plugin";
|
||||
const TASK_THREAD_PREFIX = "Codex Companion Task";
|
||||
@@ -639,7 +639,16 @@ async function startThread(client, cwd, options = {}) {
|
||||
const response = await client.request("thread/start", buildThreadParams(cwd, options));
|
||||
const threadId = response.thread.id;
|
||||
if (options.threadName) {
|
||||
await client.request("thread/name/set", { threadId, name: options.threadName });
|
||||
try {
|
||||
await client.request("thread/name/set", { threadId, name: options.threadName });
|
||||
} catch (err) {
|
||||
// Only suppress "unknown variant/method" errors from older CLI versions
|
||||
// that don't support thread/name/set. Rethrow auth, network, or server errors.
|
||||
const msg = String(err?.message ?? err ?? "");
|
||||
if (!msg.includes("unknown variant") && !msg.includes("unknown method")) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -652,6 +661,134 @@ function buildResultStatus(turnState) {
|
||||
return turnState.finalTurn?.status === "completed" ? 0 : 1;
|
||||
}
|
||||
|
||||
const BUILTIN_PROVIDER_LABELS = new Map([
|
||||
["openai", "OpenAI"],
|
||||
["ollama", "Ollama"],
|
||||
["lmstudio", "LM Studio"]
|
||||
]);
|
||||
|
||||
function normalizeProviderId(value) {
|
||||
const providerId = typeof value === "string" ? value.trim() : "";
|
||||
return providerId || null;
|
||||
}
|
||||
|
||||
function formatProviderLabel(providerId, providerConfig = null) {
|
||||
const configuredName = typeof providerConfig?.name === "string" ? providerConfig.name.trim() : "";
|
||||
if (configuredName) {
|
||||
return configuredName;
|
||||
}
|
||||
if (!providerId) {
|
||||
return "The active provider";
|
||||
}
|
||||
return BUILTIN_PROVIDER_LABELS.get(providerId) ?? providerId;
|
||||
}
|
||||
|
||||
function buildAuthStatus(fields = {}) {
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: false,
|
||||
detail: "not authenticated",
|
||||
source: "unknown",
|
||||
authMethod: null,
|
||||
verified: null,
|
||||
requiresOpenaiAuth: null,
|
||||
provider: null,
|
||||
...fields
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProviderConfig(configResponse) {
|
||||
const config = configResponse?.config;
|
||||
if (!config || typeof config !== "object") {
|
||||
return {
|
||||
providerId: null,
|
||||
providerConfig: null
|
||||
};
|
||||
}
|
||||
|
||||
const providerId = normalizeProviderId(config.model_provider);
|
||||
const providers =
|
||||
config.model_providers && typeof config.model_providers === "object" && !Array.isArray(config.model_providers)
|
||||
? config.model_providers
|
||||
: null;
|
||||
const providerConfig =
|
||||
providerId && providers?.[providerId] && typeof providers[providerId] === "object" ? providers[providerId] : null;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerConfig
|
||||
};
|
||||
}
|
||||
|
||||
function buildAppServerAuthStatus(accountResponse, configResponse) {
|
||||
const account = accountResponse?.account ?? null;
|
||||
const requiresOpenaiAuth =
|
||||
typeof accountResponse?.requiresOpenaiAuth === "boolean" ? accountResponse.requiresOpenaiAuth : null;
|
||||
const { providerId, providerConfig } = resolveProviderConfig(configResponse);
|
||||
const providerLabel = formatProviderLabel(providerId, providerConfig);
|
||||
|
||||
if (account?.type === "chatgpt") {
|
||||
const email = typeof account.email === "string" && account.email.trim() ? account.email.trim() : null;
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: email ? `ChatGPT login active for ${email}` : "ChatGPT login active",
|
||||
source: "app-server",
|
||||
authMethod: "chatgpt",
|
||||
verified: true,
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
if (account?.type === "apiKey") {
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: "API key configured (unverified)",
|
||||
source: "app-server",
|
||||
authMethod: "apiKey",
|
||||
verified: false,
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
if (requiresOpenaiAuth === false) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: true,
|
||||
detail: `${providerLabel} is configured and does not require OpenAI authentication`,
|
||||
source: "app-server",
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: `${providerLabel} requires OpenAI authentication`,
|
||||
source: "app-server",
|
||||
requiresOpenaiAuth,
|
||||
provider: providerId
|
||||
});
|
||||
}
|
||||
|
||||
async function getCodexAuthStatusFromClient(client, cwd) {
|
||||
try {
|
||||
const accountResponse = await client.request("account/read", { refreshToken: false });
|
||||
const configResponse = await client.request("config/read", {
|
||||
includeLayers: false,
|
||||
cwd
|
||||
});
|
||||
|
||||
return buildAppServerAuthStatus(accountResponse, configResponse);
|
||||
} catch (error) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
source: "app-server"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getCodexAvailability(cwd) {
|
||||
const versionStatus = binaryAvailable("codex", ["--version"], { cwd });
|
||||
if (!versionStatus.available) {
|
||||
@@ -691,38 +828,39 @@ export function getSessionRuntimeStatus(env = process.env, cwd = process.cwd())
|
||||
};
|
||||
}
|
||||
|
||||
export function getCodexLoginStatus(cwd) {
|
||||
export async function getCodexAuthStatus(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
return {
|
||||
available: false,
|
||||
loggedIn: false,
|
||||
detail: availability.detail
|
||||
detail: availability.detail,
|
||||
source: "availability",
|
||||
authMethod: null,
|
||||
verified: null,
|
||||
requiresOpenaiAuth: null,
|
||||
provider: null
|
||||
};
|
||||
}
|
||||
|
||||
const result = runCommand("codex", ["login", "status"], { cwd });
|
||||
if (result.error) {
|
||||
return {
|
||||
available: true,
|
||||
let client = null;
|
||||
try {
|
||||
client = await CodexAppServerClient.connect(cwd, {
|
||||
env: options.env,
|
||||
reuseExistingBroker: true
|
||||
});
|
||||
return await getCodexAuthStatusFromClient(client, cwd);
|
||||
} catch (error) {
|
||||
return buildAuthStatus({
|
||||
loggedIn: false,
|
||||
detail: result.error.message
|
||||
};
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
source: "app-server"
|
||||
});
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === 0) {
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: true,
|
||||
detail: result.stdout.trim() || "authenticated"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
loggedIn: false,
|
||||
detail: result.stderr.trim() || result.stdout.trim() || "not authenticated"
|
||||
};
|
||||
}
|
||||
|
||||
export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
|
||||
@@ -745,12 +883,9 @@ export async function interruptAppServerTurn(cwd, { threadId, turnId }) {
|
||||
};
|
||||
}
|
||||
|
||||
const brokerEndpoint = process.env[BROKER_ENDPOINT_ENV] ?? loadBrokerSession(cwd)?.endpoint ?? null;
|
||||
let client = null;
|
||||
try {
|
||||
client = brokerEndpoint
|
||||
? await CodexAppServerClient.connect(cwd, { brokerEndpoint })
|
||||
: await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
||||
client = await CodexAppServerClient.connect(cwd, { reuseExistingBroker: true });
|
||||
await client.request("turn/interrupt", { threadId, turnId });
|
||||
return {
|
||||
attempted: true,
|
||||
|
||||
@@ -2,9 +2,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { isProbablyText } from "./fs.mjs";
|
||||
import { runCommand, runCommandChecked } from "./process.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;
|
||||
|
||||
function git(cwd, args, options = {}) {
|
||||
return runCommand("git", args, { cwd, ...options });
|
||||
@@ -14,6 +16,64 @@ function gitChecked(cwd, args, options = {}) {
|
||||
return runCommandChecked("git", args, { cwd, ...options });
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -135,12 +195,25 @@ function formatSection(title, body) {
|
||||
|
||||
function formatUntrackedFile(cwd, relativePath) {
|
||||
const absolutePath = path.join(cwd, relativePath);
|
||||
const stat = fs.statSync(absolutePath);
|
||||
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)`;
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(absolutePath);
|
||||
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)`;
|
||||
}
|
||||
@@ -148,55 +221,115 @@ function formatUntrackedFile(cwd, relativePath) {
|
||||
return [`### ${relativePath}`, "```", buffer.toString("utf8").trimEnd(), "```"].join("\n");
|
||||
}
|
||||
|
||||
function collectWorkingTreeContext(cwd, state) {
|
||||
const status = gitChecked(cwd, ["status", "--short"]).stdout.trim();
|
||||
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");
|
||||
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);
|
||||
|
||||
const parts = [
|
||||
formatSection("Git Status", status),
|
||||
formatSection("Staged Diff", stagedDiff),
|
||||
formatSection("Unstaged Diff", unstagedDiff),
|
||||
formatSection("Untracked Files", untrackedBody)
|
||||
];
|
||||
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")
|
||||
content: parts.join("\n"),
|
||||
changedFiles
|
||||
};
|
||||
}
|
||||
|
||||
function collectBranchContext(cwd, baseRef) {
|
||||
const mergeBase = gitChecked(cwd, ["merge-base", "HEAD", baseRef]).stdout.trim();
|
||||
const commitRange = `${mergeBase}..HEAD`;
|
||||
function collectBranchContext(cwd, baseRef, options = {}) {
|
||||
const includeDiff = options.includeDiff !== false;
|
||||
const comparison = options.comparison ?? buildBranchComparison(cwd, baseRef);
|
||||
const currentBranch = getCurrentBranch(cwd);
|
||||
const logOutput = gitChecked(cwd, ["log", "--oneline", "--decorate", commitRange]).stdout.trim();
|
||||
const diffStat = gitChecked(cwd, ["diff", "--stat", commitRange]).stdout.trim();
|
||||
const diff = gitChecked(cwd, ["diff", "--binary", "--no-ext-diff", "--submodule=diff", commitRange]).stdout;
|
||||
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 ${mergeBase}.`,
|
||||
content: [
|
||||
formatSection("Commit Log", logOutput),
|
||||
formatSection("Diff Stat", diffStat),
|
||||
formatSection("Branch Diff", diff)
|
||||
].join("\n")
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
export function collectReviewContext(cwd, target) {
|
||||
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 state = getWorkingTreeState(cwd);
|
||||
const currentBranch = getCurrentBranch(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") {
|
||||
details = collectWorkingTreeContext(repoRoot, state);
|
||||
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 {
|
||||
details = collectBranchContext(repoRoot, target.baseRef);
|
||||
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 {
|
||||
@@ -204,6 +337,10 @@ export function collectReviewContext(cwd, target) {
|
||||
repoRoot,
|
||||
branch: currentBranch,
|
||||
target,
|
||||
fileCount: details.changedFiles.length,
|
||||
diffBytes,
|
||||
inputMode: includeDiff ? "inline-diff" : "self-collect",
|
||||
collectionGuidance: buildAdversarialCollectionGuidance({ includeDiff }),
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ export function buildStatusSnapshot(cwd, options = {}) {
|
||||
return {
|
||||
workspaceRoot,
|
||||
config,
|
||||
sessionRuntime: getSessionRuntimeStatus(options.env),
|
||||
sessionRuntime: getSessionRuntimeStatus(options.env, workspaceRoot),
|
||||
running,
|
||||
latestFinished,
|
||||
recent,
|
||||
@@ -278,7 +278,7 @@ export function resolveResultJob(cwd, reference) {
|
||||
throw new Error("No finished Codex jobs found for this repository yet.");
|
||||
}
|
||||
|
||||
export function resolveCancelableJob(cwd, reference) {
|
||||
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");
|
||||
@@ -291,12 +291,18 @@ export function resolveCancelableJob(cwd, reference) {
|
||||
return { workspaceRoot, job: selected };
|
||||
}
|
||||
|
||||
if (activeJobs.length === 1) {
|
||||
return { workspaceRoot, job: activeJobs[0] };
|
||||
const sessionScopedActiveJobs = filterJobsForCurrentSession(activeJobs, options);
|
||||
|
||||
if (sessionScopedActiveJobs.length === 1) {
|
||||
return { workspaceRoot, job: sessionScopedActiveJobs[0] };
|
||||
}
|
||||
if (activeJobs.length > 1) {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ export function runCommand(command, args = [], options = {}) {
|
||||
env: options.env,
|
||||
encoding: "utf8",
|
||||
input: options.input,
|
||||
maxBuffer: options.maxBuffer,
|
||||
stdio: options.stdio ?? "pipe",
|
||||
shell: process.platform === "win32",
|
||||
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { getCodexLoginStatus } from "./lib/codex.mjs";
|
||||
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";
|
||||
@@ -57,13 +57,13 @@ function buildStopReviewPrompt(input = {}) {
|
||||
}
|
||||
|
||||
function buildSetupNote(cwd) {
|
||||
const authStatus = getCodexLoginStatus(cwd);
|
||||
if (authStatus.available && authStatus.loggedIn) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (availability.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = authStatus.detail ? ` ${authStatus.detail}.` : "";
|
||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup and, if needed, !codex login.`;
|
||||
const detail = availability.detail ? ` ${availability.detail}.` : "";
|
||||
return `Codex is not set up for the review gate.${detail} Run /codex:setup.`;
|
||||
}
|
||||
|
||||
function parseStopReviewOutput(rawOutput) {
|
||||
@@ -175,4 +175,10 @@ function main() {
|
||||
logNote(runningTaskNote);
|
||||
}
|
||||
|
||||
main();
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const VERSION_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
file: "package.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "package-lock.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "packages[\"\"].version",
|
||||
get: (json) => json.packages?.[""]?.version,
|
||||
set: (json, version) => {
|
||||
requireObject(json.packages?.[""], "package-lock.json packages[\"\"]");
|
||||
json.packages[""].version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "plugins/codex/.claude-plugin/plugin.json",
|
||||
values: [
|
||||
{
|
||||
label: "version",
|
||||
get: (json) => json.version,
|
||||
set: (json, version) => {
|
||||
json.version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
file: ".claude-plugin/marketplace.json",
|
||||
values: [
|
||||
{
|
||||
label: "metadata.version",
|
||||
get: (json) => json.metadata?.version,
|
||||
set: (json, version) => {
|
||||
requireObject(json.metadata, ".claude-plugin/marketplace.json metadata");
|
||||
json.metadata.version = version;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "plugins[codex].version",
|
||||
get: (json) => findMarketplacePlugin(json).version,
|
||||
set: (json, version) => {
|
||||
findMarketplacePlugin(json).version = version;
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage:",
|
||||
" node scripts/bump-version.mjs <version>",
|
||||
" node scripts/bump-version.mjs --check [version]",
|
||||
"",
|
||||
"Options:",
|
||||
" --check Verify manifest versions. Uses package.json when version is omitted.",
|
||||
" --root <dir> Run against a different repository root.",
|
||||
" --help Print this help."
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
check: false,
|
||||
root: process.cwd(),
|
||||
version: null
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg === "--check") {
|
||||
options.check = true;
|
||||
} else if (arg === "--root") {
|
||||
const root = argv[i + 1];
|
||||
if (!root) {
|
||||
throw new Error("--root requires a directory.");
|
||||
}
|
||||
options.root = root;
|
||||
i += 1;
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg.startsWith("-")) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
} else if (options.version) {
|
||||
throw new Error(`Unexpected extra argument: ${arg}`);
|
||||
} else {
|
||||
options.version = arg;
|
||||
}
|
||||
}
|
||||
|
||||
options.root = path.resolve(options.root);
|
||||
return options;
|
||||
}
|
||||
|
||||
function validateVersion(version) {
|
||||
if (!VERSION_PATTERN.test(version)) {
|
||||
throw new Error(`Expected a semver-like version such as 1.0.3, got: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
function requireObject(value, label) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`Expected ${label} to be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function findMarketplacePlugin(json) {
|
||||
const plugin = json.plugins?.find((entry) => entry?.name === "codex");
|
||||
requireObject(plugin, ".claude-plugin/marketplace.json plugins[codex]");
|
||||
return plugin;
|
||||
}
|
||||
|
||||
function readJson(root, file) {
|
||||
const filePath = path.join(root, file);
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function writeJson(root, file, json) {
|
||||
const filePath = path.join(root, file);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readPackageVersion(root) {
|
||||
const packageJson = readJson(root, "package.json");
|
||||
if (typeof packageJson.version !== "string") {
|
||||
throw new Error("package.json version must be a string.");
|
||||
}
|
||||
validateVersion(packageJson.version);
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
function checkVersions(root, expectedVersion) {
|
||||
const mismatches = [];
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const json = readJson(root, target.file);
|
||||
for (const value of target.values) {
|
||||
const actual = value.get(json);
|
||||
if (actual !== expectedVersion) {
|
||||
mismatches.push(`${target.file} ${value.label}: expected ${expectedVersion}, found ${actual ?? "<missing>"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function bumpVersion(root, version) {
|
||||
const changedFiles = [];
|
||||
|
||||
for (const target of TARGETS) {
|
||||
const json = readJson(root, target.file);
|
||||
const before = JSON.stringify(json);
|
||||
|
||||
for (const value of target.values) {
|
||||
value.set(json, version);
|
||||
}
|
||||
|
||||
if (JSON.stringify(json) !== before) {
|
||||
writeJson(root, target.file, json);
|
||||
changedFiles.push(target.file);
|
||||
}
|
||||
}
|
||||
|
||||
return changedFiles;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const version = options.version ?? (options.check ? readPackageVersion(options.root) : null);
|
||||
if (!version) {
|
||||
throw new Error(`Missing version.\n\n${usage()}`);
|
||||
}
|
||||
validateVersion(version);
|
||||
|
||||
if (options.check) {
|
||||
const mismatches = checkVersions(options.root, version);
|
||||
if (mismatches.length > 0) {
|
||||
throw new Error(`Version metadata is out of sync:\n${mismatches.join("\n")}`);
|
||||
}
|
||||
console.log(`All version metadata matches ${version}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const changedFiles = bumpVersion(options.root, version);
|
||||
const touched = changedFiles.length > 0 ? changedFiles.join(", ") : "no files changed";
|
||||
console.log(`Set version metadata to ${version}: ${touched}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { makeTempDir, run } from "./helpers.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SCRIPT = path.join(ROOT, "scripts", "bump-version.mjs");
|
||||
|
||||
function writeJson(filePath, json) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function makeVersionFixture() {
|
||||
const root = makeTempDir();
|
||||
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, "package-lock.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.2"
|
||||
}
|
||||
}
|
||||
});
|
||||
writeJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json"), {
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
});
|
||||
writeJson(path.join(root, ".claude-plugin", "marketplace.json"), {
|
||||
metadata: {
|
||||
version: "1.0.2"
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "codex",
|
||||
version: "1.0.2"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
test("bump-version updates every release manifest", () => {
|
||||
const root = makeVersionFixture();
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "1.2.3"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(readJson(path.join(root, "package.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "package-lock.json")).packages[""].version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, "plugins", "codex", ".claude-plugin", "plugin.json")).version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).metadata.version, "1.2.3");
|
||||
assert.equal(readJson(path.join(root, ".claude-plugin", "marketplace.json")).plugins[0].version, "1.2.3");
|
||||
});
|
||||
|
||||
test("bump-version check mode reports stale metadata", () => {
|
||||
const root = makeVersionFixture();
|
||||
writeJson(path.join(root, "package.json"), {
|
||||
name: "@openai/codex-plugin-cc",
|
||||
version: "1.0.3"
|
||||
});
|
||||
|
||||
const result = run("node", [SCRIPT, "--root", root, "--check"], {
|
||||
cwd: ROOT
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /plugins\/codex\/\.claude-plugin\/plugin\.json version/);
|
||||
assert.match(result.stderr, /\.claude-plugin\/marketplace\.json metadata\.version/);
|
||||
});
|
||||
+12
-3
@@ -90,7 +90,16 @@ test("rescue command absorbs continue semantics", () => {
|
||||
const runtimeSkill = read("skills/codex-cli-runtime/SKILL.md");
|
||||
|
||||
assert.match(rescue, /The final user-visible response must be Codex's output verbatim/i);
|
||||
assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion/);
|
||||
assert.match(rescue, /allowed-tools:\s*Bash\(node:\*\),\s*AskUserQuestion,\s*Agent/);
|
||||
// Regression for #234: `Skill(codex:rescue)` from the main agent recursed
|
||||
// because rescue.md named the routing with ambiguous prose ("Route this
|
||||
// request to the `codex:codex-rescue` subagent") while running under
|
||||
// `context: fork` — forked general-purpose subagents do not expose the
|
||||
// `Agent` tool, so the fork fell back to `Skill` and re-entered this
|
||||
// command. Pin the explicit transport and the inline (no-fork) execution.
|
||||
assert.match(rescue, /subagent_type: "codex:codex-rescue"/);
|
||||
assert.match(rescue, /do not call `Skill\(codex:codex-rescue\)`/i);
|
||||
assert.doesNotMatch(rescue, /^context:\s*fork\b/m);
|
||||
assert.match(rescue, /--background\|--wait/);
|
||||
assert.match(rescue, /--resume\|--fresh/);
|
||||
assert.match(rescue, /--model <model\|spark>/);
|
||||
@@ -165,9 +174,9 @@ test("result and cancel commands are exposed as deterministic runtime entrypoint
|
||||
const resultHandling = read("skills/codex-result-handling/SKILL.md");
|
||||
|
||||
assert.match(result, /disable-model-invocation:\s*true/);
|
||||
assert.match(result, /codex-companion\.mjs" result \$ARGUMENTS/);
|
||||
assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/);
|
||||
assert.match(cancel, /disable-model-invocation:\s*true/);
|
||||
assert.match(cancel, /codex-companion\.mjs" cancel \$ARGUMENTS/);
|
||||
assert.match(cancel, /codex-companion\.mjs" cancel "\$ARGUMENTS"/);
|
||||
assert.match(resultHandling, /do not turn a failed or incomplete Codex run into a Claude-side implementation attempt/i);
|
||||
assert.match(resultHandling, /if Codex was never successfully invoked, do not generate a substitute answer at all/i);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,54 @@ function buildTurn(id, status = "inProgress", error = null) {
|
||||
return { id, status, items: [], error };
|
||||
}
|
||||
|
||||
function buildAccountReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "logged-out":
|
||||
case "refreshable-auth":
|
||||
case "auth-run-fails":
|
||||
return { account: null, requiresOpenaiAuth: true };
|
||||
case "provider-no-auth":
|
||||
case "env-key-provider":
|
||||
return { account: null, requiresOpenaiAuth: false };
|
||||
case "api-key-account-only":
|
||||
return { account: { type: "apiKey" }, requiresOpenaiAuth: true };
|
||||
default:
|
||||
return {
|
||||
account: { type: "chatgpt", email: "test@example.com", planType: "plus" },
|
||||
requiresOpenaiAuth: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildConfigReadResult() {
|
||||
switch (BEHAVIOR) {
|
||||
case "provider-no-auth":
|
||||
return {
|
||||
config: { model_provider: "ollama" },
|
||||
origins: {}
|
||||
};
|
||||
case "env-key-provider":
|
||||
return {
|
||||
config: {
|
||||
model_provider: "openai-custom",
|
||||
model_providers: {
|
||||
"openai-custom": {
|
||||
name: "OpenAI custom",
|
||||
env_key: "OPENAI_API_KEY",
|
||||
requires_openai_auth: false
|
||||
}
|
||||
}
|
||||
},
|
||||
origins: {}
|
||||
};
|
||||
default:
|
||||
return {
|
||||
config: { model_provider: "openai" },
|
||||
origins: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + "\\n");
|
||||
}
|
||||
@@ -193,7 +241,7 @@ if (args[0] === "app-server" && args[1] === "--help") {
|
||||
process.exit(0);
|
||||
}
|
||||
if (args[0] === "login" && args[1] === "status") {
|
||||
if (BEHAVIOR === "logged-out") {
|
||||
if (BEHAVIOR === "logged-out" || BEHAVIOR === "refreshable-auth" || BEHAVIOR === "auth-run-fails" || BEHAVIOR === "provider-no-auth" || BEHAVIOR === "env-key-provider" || BEHAVIOR === "api-key-account-only") {
|
||||
console.error("not authenticated");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -230,7 +278,21 @@ rl.on("line", (line) => {
|
||||
case "initialized":
|
||||
break;
|
||||
|
||||
case "account/read":
|
||||
send({ id: message.id, result: buildAccountReadResult() });
|
||||
break;
|
||||
|
||||
case "config/read":
|
||||
if (BEHAVIOR === "config-read-fails") {
|
||||
throw new Error("config/read failed for cwd");
|
||||
}
|
||||
send({ id: message.id, result: buildConfigReadResult() });
|
||||
break;
|
||||
|
||||
case "thread/start": {
|
||||
if (BEHAVIOR === "auth-run-fails") {
|
||||
throw new Error("authentication expired; run codex login");
|
||||
}
|
||||
if (requiresExperimental("persistExtendedHistory", message, state) || requiresExperimental("persistFullHistory", message, state)) {
|
||||
throw new Error("thread/start.persistFullHistory requires experimentalApi capability");
|
||||
}
|
||||
|
||||
@@ -68,3 +68,116 @@ test("resolveReviewTarget requires an explicit base when no default branch can b
|
||||
/Unable to detect the repository default branch\. Pass --base <ref> or use --scope working-tree\./
|
||||
);
|
||||
});
|
||||
|
||||
test("collectReviewContext keeps inline diffs for tiny adversarial reviews", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('INLINE_MARKER');\n");
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "inline-diff");
|
||||
assert.equal(context.fileCount, 1);
|
||||
assert.match(context.collectionGuidance, /primary evidence/i);
|
||||
assert.match(context.content, /INLINE_MARKER/);
|
||||
});
|
||||
|
||||
test("collectReviewContext skips untracked directories in working tree review", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
|
||||
const nestedRepoDir = path.join(cwd, ".claude", "worktrees", "agent-test");
|
||||
fs.mkdirSync(nestedRepoDir, { recursive: true });
|
||||
initGitRepo(nestedRepoDir);
|
||||
|
||||
const target = resolveReviewTarget(cwd, { scope: "working-tree" });
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.match(context.content, /### \.claude\/worktrees\/agent-test\/\n\(skipped: directory\)/);
|
||||
});
|
||||
|
||||
test("collectReviewContext skips broken untracked symlinks instead of crashing", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "console.log('v1');\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.symlinkSync("missing-target", path.join(cwd, "broken-link"));
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(target.mode, "working-tree");
|
||||
assert.match(context.content, /### broken-link/);
|
||||
assert.match(context.content, /skipped: broken symlink or unreadable file/i);
|
||||
});
|
||||
|
||||
test("collectReviewContext falls back to lightweight context for larger adversarial reviews", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
for (const name of ["a.js", "b.js", "c.js"]) {
|
||||
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "a.js", "b.js", "c.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "SELF_COLLECT_MARKER_A";\n');
|
||||
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "SELF_COLLECT_MARKER_B";\n');
|
||||
fs.writeFileSync(path.join(cwd, "c.js"), 'export const value = "SELF_COLLECT_MARKER_C";\n');
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.equal(context.fileCount, 3);
|
||||
assert.match(context.collectionGuidance, /lightweight summary/i);
|
||||
assert.match(context.collectionGuidance, /read-only git commands/i);
|
||||
assert.doesNotMatch(context.content, /SELF_COLLECT_MARKER_[ABC]/);
|
||||
assert.match(context.content, /## Changed Files/);
|
||||
});
|
||||
|
||||
test("collectReviewContext falls back to lightweight context for oversized single-file diffs", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), "export const value = 'v1';\n");
|
||||
run("git", ["add", "app.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "app.js"), `export const value = '${"x".repeat(512)}';\n`);
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target, { maxInlineDiffBytes: 128 });
|
||||
|
||||
assert.equal(context.fileCount, 1);
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.ok(context.diffBytes > 128);
|
||||
assert.doesNotMatch(context.content, /xxx/);
|
||||
assert.match(context.content, /## Changed Files/);
|
||||
});
|
||||
|
||||
test("collectReviewContext keeps untracked file content in lightweight working tree context", () => {
|
||||
const cwd = makeTempDir();
|
||||
initGitRepo(cwd);
|
||||
for (const name of ["a.js", "b.js"]) {
|
||||
fs.writeFileSync(path.join(cwd, name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "a.js", "b.js"], { cwd });
|
||||
run("git", ["commit", "-m", "init"], { cwd });
|
||||
fs.writeFileSync(path.join(cwd, "a.js"), 'export const value = "TRACKED_MARKER_A";\n');
|
||||
fs.writeFileSync(path.join(cwd, "b.js"), 'export const value = "TRACKED_MARKER_B";\n');
|
||||
fs.writeFileSync(path.join(cwd, "new-risk.js"), 'export const value = "UNTRACKED_RISK_MARKER";\n');
|
||||
|
||||
const target = resolveReviewTarget(cwd, {});
|
||||
const context = collectReviewContext(cwd, target);
|
||||
|
||||
assert.equal(context.inputMode, "self-collect");
|
||||
assert.equal(context.fileCount, 3);
|
||||
assert.doesNotMatch(context.content, /TRACKED_MARKER_[AB]/);
|
||||
assert.match(context.content, /## Untracked Files/);
|
||||
assert.match(context.content, /UNTRACKED_RISK_MARKER/);
|
||||
});
|
||||
|
||||
+429
-7
@@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
|
||||
import { initGitRepo, makeTempDir, run } from "./helpers.mjs";
|
||||
import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
|
||||
import { loadBrokerSession, saveBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
|
||||
import { resolveStateDir } from "../plugins/codex/scripts/lib/state.mjs";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -65,6 +65,77 @@ test("setup is ready without npm when Codex is already installed and authenticat
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
});
|
||||
|
||||
test("setup trusts app-server API key auth even when login status alone would fail", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "api-key-account-only");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, "apiKey");
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /API key configured \(unverified\)/);
|
||||
});
|
||||
|
||||
test("setup is ready when the active provider does not require OpenAI login", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "provider-no-auth");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, null);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
|
||||
});
|
||||
|
||||
test("setup treats custom providers with app-server-ready config as ready", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "env-key-provider");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, true);
|
||||
assert.equal(payload.auth.loggedIn, true);
|
||||
assert.equal(payload.auth.authMethod, null);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /configured and does not require OpenAI authentication/i);
|
||||
});
|
||||
|
||||
test("setup reports not ready when app-server config read fails", () => {
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "config-read-fails");
|
||||
|
||||
const result = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: ROOT,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.ready, false);
|
||||
assert.equal(payload.auth.loggedIn, false);
|
||||
assert.equal(payload.auth.source, "app-server");
|
||||
assert.match(payload.auth.detail, /config\/read failed for cwd/);
|
||||
});
|
||||
|
||||
test("review renders a no-findings result from app-server review/start", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -86,6 +157,60 @@ test("review renders a no-findings result from app-server review/start", () => {
|
||||
assert.match(result.stdout, /No material issues found/);
|
||||
});
|
||||
|
||||
test("task runs when the active provider does not require OpenAI login", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "provider-no-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check auth preflight"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Handled the requested task/);
|
||||
});
|
||||
|
||||
test("task runs without auth preflight so Codex can refresh an expired session", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "refreshable-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check refreshable auth"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Handled the requested task/);
|
||||
});
|
||||
|
||||
test("task reports the actual Codex auth error when the run is rejected", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "auth-run-fails");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const result = run("node", [SCRIPT, "task", "check failed auth"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /authentication expired; run codex login/);
|
||||
});
|
||||
|
||||
test("review accepts the quoted raw argument style for built-in base-branch review", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -148,6 +273,33 @@ test("adversarial review accepts the same base-branch targeting as review", () =
|
||||
assert.match(result.stdout, /Missing empty-state guard/);
|
||||
});
|
||||
|
||||
test("adversarial review asks Codex to inspect larger diffs itself", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.mkdirSync(path.join(repo, "src"));
|
||||
for (const name of ["a.js", "b.js", "c.js"]) {
|
||||
fs.writeFileSync(path.join(repo, "src", name), `export const value = "${name}-v1";\n`);
|
||||
}
|
||||
run("git", ["add", "src/a.js", "src/b.js", "src/c.js"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, "src", "a.js"), 'export const value = "PROMPT_SELF_COLLECT_A";\n');
|
||||
fs.writeFileSync(path.join(repo, "src", "b.js"), 'export const value = "PROMPT_SELF_COLLECT_B";\n');
|
||||
fs.writeFileSync(path.join(repo, "src", "c.js"), 'export const value = "PROMPT_SELF_COLLECT_C";\n');
|
||||
|
||||
const result = run("node", [SCRIPT, "adversarial-review"], {
|
||||
cwd: repo,
|
||||
env: buildEnv(binDir)
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
|
||||
assert.match(state.lastTurnStart.prompt, /lightweight summary/i);
|
||||
assert.match(state.lastTurnStart.prompt, /read-only git commands/i);
|
||||
assert.doesNotMatch(state.lastTurnStart.prompt, /PROMPT_SELF_COLLECT_[ABC]/);
|
||||
});
|
||||
|
||||
test("review includes reasoning output when the app server returns it", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -284,6 +436,105 @@ test("task-resume-candidate returns the latest rescue thread from the current se
|
||||
assert.equal(payload.candidate.threadId, "thr_current");
|
||||
});
|
||||
|
||||
test("task --resume-last does not resume a task from another Claude session", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
const statePath = path.join(binDir, "fake-codex-state.json");
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const otherEnv = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-other"
|
||||
};
|
||||
const currentEnv = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
|
||||
const firstRun = run("node", [SCRIPT, "task", "initial task"], {
|
||||
cwd: repo,
|
||||
env: otherEnv
|
||||
});
|
||||
assert.equal(firstRun.status, 0, firstRun.stderr);
|
||||
|
||||
const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], {
|
||||
cwd: repo,
|
||||
env: currentEnv
|
||||
});
|
||||
assert.equal(candidate.status, 0, candidate.stderr);
|
||||
assert.equal(JSON.parse(candidate.stdout).available, false);
|
||||
|
||||
const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
|
||||
cwd: repo,
|
||||
env: currentEnv
|
||||
});
|
||||
assert.equal(resume.status, 1);
|
||||
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
||||
assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
|
||||
assert.equal(fakeState.lastTurnStart.prompt, "initial task");
|
||||
});
|
||||
|
||||
test("task --resume-last ignores running tasks from other Claude sessions", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
|
||||
const stateDir = resolveStateDir(repo);
|
||||
fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other-running",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
threadId: "thr_other",
|
||||
summary: "Other session active task",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...buildEnv(binDir),
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const status = run("node", [SCRIPT, "status", "--json"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.deepEqual(JSON.parse(status.stdout).running, []);
|
||||
|
||||
const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(resume.status, 1);
|
||||
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
|
||||
});
|
||||
|
||||
test("session start hook exports the Claude session id and plugin data dir for later commands", () => {
|
||||
const repo = makeTempDir();
|
||||
const envFile = path.join(makeTempDir(), "claude-env.sh");
|
||||
@@ -1247,6 +1498,109 @@ test("cancel stops an active background job and marks it cancelled", async (t) =
|
||||
assert.match(fs.readFileSync(logFile, "utf8"), /Cancelled by user/);
|
||||
});
|
||||
|
||||
test("cancel without a job id ignores active jobs from other Claude sessions", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
const jobsDir = path.join(stateDir, "jobs");
|
||||
fs.mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
const logFile = path.join(jobsDir, "task-other.log");
|
||||
fs.writeFileSync(logFile, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
summary: "Other session run",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z",
|
||||
logFile
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const status = run("node", [SCRIPT, "status", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.deepEqual(JSON.parse(status.stdout).running, []);
|
||||
|
||||
const cancel = run("node", [SCRIPT, "cancel", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(cancel.status, 1);
|
||||
assert.match(cancel.stderr, /No active Codex jobs to cancel for this session\./);
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
|
||||
assert.equal(state.jobs[0].status, "running");
|
||||
});
|
||||
|
||||
test("cancel with a job id can still target an active job from another Claude session", () => {
|
||||
const workspace = makeTempDir();
|
||||
const stateDir = resolveStateDir(workspace);
|
||||
const jobsDir = path.join(stateDir, "jobs");
|
||||
fs.mkdirSync(jobsDir, { recursive: true });
|
||||
|
||||
const logFile = path.join(jobsDir, "task-other.log");
|
||||
fs.writeFileSync(logFile, "", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(stateDir, "state.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
config: { stopReviewGate: false },
|
||||
jobs: [
|
||||
{
|
||||
id: "task-other",
|
||||
status: "running",
|
||||
title: "Codex Task",
|
||||
jobClass: "task",
|
||||
sessionId: "sess-other",
|
||||
summary: "Other session run",
|
||||
updatedAt: "2026-03-24T20:05:00.000Z",
|
||||
logFile
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CODEX_COMPANION_SESSION_ID: "sess-current"
|
||||
};
|
||||
const cancel = run("node", [SCRIPT, "cancel", "task-other", "--json"], {
|
||||
cwd: workspace,
|
||||
env
|
||||
});
|
||||
assert.equal(cancel.status, 0, cancel.stderr);
|
||||
assert.equal(JSON.parse(cancel.stdout).jobId, "task-other");
|
||||
|
||||
const state = JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8"));
|
||||
assert.equal(state.jobs[0].status, "cancelled");
|
||||
});
|
||||
|
||||
test("cancel sends turn interrupt to the shared app-server before killing a brokered task", async () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -1598,10 +1952,10 @@ test("stop hook does not block when Codex is unavailable even if the review gate
|
||||
assert.match(allowed.stderr, /Run \/codex:setup/i);
|
||||
});
|
||||
|
||||
test("stop hook does not block when Codex is not authenticated even if the review gate is enabled", () => {
|
||||
test("stop hook runs the actual task when auth status looks stale", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
installFakeCodex(binDir, "logged-out");
|
||||
installFakeCodex(binDir, "refreshable-auth");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
@@ -1620,10 +1974,10 @@ test("stop hook does not block when Codex is not authenticated even if the revie
|
||||
});
|
||||
|
||||
assert.equal(allowed.status, 0, allowed.stderr);
|
||||
assert.equal(allowed.stdout.trim(), "");
|
||||
assert.match(allowed.stderr, /Codex is not set up for the review gate/i);
|
||||
assert.match(allowed.stderr, /not authenticated/i);
|
||||
assert.match(allowed.stderr, /!codex login/i);
|
||||
assert.doesNotMatch(allowed.stderr, /Codex is not set up for the review gate/i);
|
||||
const payload = JSON.parse(allowed.stdout);
|
||||
assert.equal(payload.decision, "block");
|
||||
assert.match(payload.reason, /Missing empty-state guard/i);
|
||||
});
|
||||
|
||||
test("commands lazily start and reuse one shared app-server after first use", async () => {
|
||||
@@ -1671,6 +2025,51 @@ test("commands lazily start and reuse one shared app-server after first use", as
|
||||
assert.equal(cleanup.status, 0, cleanup.stderr);
|
||||
});
|
||||
|
||||
test("setup reuses an existing shared app-server without starting another one", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
const fakeStatePath = path.join(binDir, "fake-codex-state.json");
|
||||
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
|
||||
run("git", ["add", "README.md"], { cwd: repo });
|
||||
run("git", ["commit", "-m", "init"], { cwd: repo });
|
||||
fs.writeFileSync(path.join(repo, "README.md"), "hello again\n");
|
||||
|
||||
const env = buildEnv(binDir);
|
||||
|
||||
const review = run("node", [SCRIPT, "review"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(review.status, 0, review.stderr);
|
||||
|
||||
const brokerSession = loadBrokerSession(repo);
|
||||
if (!brokerSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setup = run("node", [SCRIPT, "setup", "--json"], {
|
||||
cwd: repo,
|
||||
env
|
||||
});
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(fakeStatePath, "utf8"));
|
||||
assert.equal(fakeState.appServerStarts, 1);
|
||||
|
||||
const cleanup = run("node", [SESSION_HOOK, "SessionEnd"], {
|
||||
cwd: repo,
|
||||
env,
|
||||
input: JSON.stringify({
|
||||
hook_event_name: "SessionEnd",
|
||||
cwd: repo
|
||||
})
|
||||
});
|
||||
assert.equal(cleanup.status, 0, cleanup.stderr);
|
||||
});
|
||||
|
||||
test("status reports shared session runtime when a lazy broker is active", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@@ -1699,3 +2098,26 @@ test("status reports shared session runtime when a lazy broker is active", () =>
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /Session runtime: shared session/);
|
||||
});
|
||||
|
||||
test("setup and status honor --cwd when reading shared session runtime", () => {
|
||||
const targetWorkspace = makeTempDir();
|
||||
const invocationWorkspace = makeTempDir();
|
||||
|
||||
saveBrokerSession(targetWorkspace, {
|
||||
endpoint: "unix:/tmp/fake-broker.sock"
|
||||
});
|
||||
|
||||
const status = run("node", [SCRIPT, "status", "--cwd", targetWorkspace], {
|
||||
cwd: invocationWorkspace
|
||||
});
|
||||
assert.equal(status.status, 0, status.stderr);
|
||||
assert.match(status.stdout, /Session runtime: shared session/);
|
||||
|
||||
const setup = run("node", [SCRIPT, "setup", "--cwd", targetWorkspace, "--json"], {
|
||||
cwd: invocationWorkspace
|
||||
});
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const payload = JSON.parse(setup.stdout);
|
||||
assert.equal(payload.sessionRuntime.mode, "shared");
|
||||
assert.equal(payload.sessionRuntime.endpoint, "unix:/tmp/fake-broker.sock");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user