Compare commits

..

8 Commits

Author SHA1 Message Date
Dominik Kundel b5938a651e bump: update plugin version to 1.0.3 2026-04-07 22:09:46 -07:00
VOIDXAI d216a5fdea codex: scope default cancel selection to the current Claude session (#84)
Co-authored-by: VOIDXAI <VOIDXAI@users.noreply.github.com>
2026-04-07 20:38:58 -07:00
VOIDXAI 40d213d13f codex: scope implicit resume-last selection to the current Claude session (#83)
Co-authored-by: VOIDXAI <VOIDXAI@users.noreply.github.com>
2026-04-07 20:15:03 -07:00
Trevin Chow 4bd783b7ce fix: gracefully handle unsupported thread/name/set on older Codex CLI (#126)
* fix: gracefully handle unsupported thread/name/set on older Codex CLI

Codex CLI v0.118.0 does not recognize the thread/name/set JSON-RPC method,
causing startThread() to throw. Thread naming is cosmetic (for job log
labels) and should not block thread creation. Wraps the call in try/catch
so it fails silently on older CLI versions.

Fixes #119

* refactor: only suppress unsupported-method errors for thread/name/set

Address Codex review feedback: the bare catch swallowed all errors
including auth, network, and server failures. Now only suppresses
errors containing 'unknown variant' or 'unknown method' (the specific
error older CLI versions return) and rethrows everything else.
2026-04-07 20:08:25 -07:00
Bhuvanesh Sridharan dd335cbc76 fix: inherit process.env in app-server spawn when no explicit env is provided (#159)
`SpawnedCodexAppServerClient.initialize()` passes `this.options.env` to
`spawn()`, but no caller ever sets `env` in options. In Node.js, passing
`undefined` for `env` gives the child process **no** environment variables,
breaking any model provider that relies on env vars (e.g. DATABRICKS_TOKEN).

Fall back to `process.env` when `this.options.env` is not set, matching the
existing pattern in `broker-lifecycle.mjs` and `codex-companion.mjs`.

Co-authored-by: Isaac

Co-authored-by: Bhuvanesh Sridharan <bhuvanesh.sridharan@databricks.com>
2026-04-07 20:07:29 -07:00
Dominik Kundel 62c351a7bf Use app-server auth status for Codex readiness (#177)
* Use app-server auth status for Codex readiness

* fix: reuse existing app server for auth checks
2026-04-07 20:06:22 -07:00
Dominik Kundel f17e7f8486 fix: respect SHELL on Windows for Git Bash (#178) 2026-04-07 19:56:34 -07:00
Dominik Kundel 594fd1e8da Fix working-tree review crash on untracked directories (#166)
* fix: skip untracked directories in review context

* fix: skip broken untracked symlinks in reviews
2026-04-06 20:45:33 -07:00
16 changed files with 1053 additions and 86 deletions
+2 -2
View File
@@ -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.3"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.2",
"version": "1.0.3",
"author": {
"name": "OpenAI"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.2",
"version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openai/codex-plugin-cc",
"version": "1.0.2",
"version": "1.0.3",
"license": "Apache-2.0",
"devDependencies": {
"@types/node": "^25.5.0",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.2",
"version": "1.0.3",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "1.0.2",
"version": "1.0.3",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
+49 -30
View File
@@ -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`.");
}
@@ -209,7 +209,7 @@ function buildSetupReport(cwd, actionsTaken = []) {
};
}
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);
}
@@ -245,14 +245,11 @@ function buildAdversarialReviewPrompt(context, focusText) {
});
}
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 +287,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 +331,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 +456,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 +755,7 @@ async function handleTask(argv) {
});
if (options.background) {
ensureCodexReady(cwd);
ensureCodexAvailable(cwd);
requireTaskRequest(prompt, resumeLast);
const job = buildTaskJob(workspaceRoot, taskMetadata, write);
@@ -862,17 +889,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 +924,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 +986,7 @@ async function main() {
switch (subcommand) {
case "setup":
handleSetup(argv);
await handleSetup(argv);
break;
case "review":
await handleReview(argv);
+1
View File
@@ -51,6 +51,7 @@ export interface CodexAppServerClientOptions {
capabilities?: InitializeCapabilities;
brokerEndpoint?: string;
disableBroker?: boolean;
reuseExistingBroker?: boolean;
}
export interface AppServerMethodMap {
+6 -3
View File
@@ -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,7 +188,7 @@ 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" ? (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;
}
+163 -28
View File
@@ -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,
+15 -2
View File
@@ -135,12 +135,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)`;
}
+10 -4
View File
@@ -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.");
}
@@ -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;
}
+227
View File
@@ -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;
}
+88
View File
@@ -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/);
});
+63 -1
View File
@@ -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");
}
+33
View File
@@ -68,3 +68,36 @@ 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 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);
});
+378 -6
View File
@@ -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();
@@ -284,6 +409,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 +1471,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 +1925,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 +1947,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 +1998,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();