Skip to content
1 change: 1 addition & 0 deletions plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Forwarding rules:
- If the user asks for `spark`, map that to `--model gpt-5.3-codex-spark`.
- If the user asks for a concrete model name such as `gpt-5.4-mini`, pass it through with `--model`.
- Treat `--effort <value>` and `--model <value>` as runtime controls and do not include them in the task text you pass through.
- If the user includes `--worktree`, add `--worktree` to the forwarded `task` call. Do not include it in the task text. This runs Codex in an isolated git worktree.
- Default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits.
- Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through.
- `--resume` means add `--resume-last`.
Expand Down
3 changes: 2 additions & 1 deletion plugins/codex/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
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]"
argument-hint: "[--background|--wait] [--worktree] [--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
---
Expand All @@ -18,6 +18,7 @@ Execution mode:
- If neither flag is present, default to foreground.
- `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text.
- `--model` and `--effort` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text.
- `--worktree` is an isolation flag. Preserve it for the forwarded `task` call, but do not treat it as part of the natural-language task text. When present, Codex runs in an isolated git worktree instead of editing the working directory in-place.
- If the request includes `--resume`, do not ask whether to continue. The user already chose.
- If the request includes `--fresh`, do not ask whether to continue. The user already chose.
- Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running:
Expand Down
91 changes: 83 additions & 8 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
runAppServerTurn
} from "./lib/codex.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import { collectReviewContext, ensureGitRepository, getRepoRoot, resolveReviewTarget } from "./lib/git.mjs";
import { createWorktreeSession, diffWorktreeSession, cleanupWorktreeSession } from "./lib/worktree.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import {
Expand Down Expand Up @@ -59,7 +60,9 @@ import {
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
renderTaskResult
renderTaskResult,
renderWorktreeTaskResult,
renderWorktreeCleanupResult
} from "./lib/render.mjs";

const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
Expand All @@ -77,7 +80,8 @@ function printUsage() {
" node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]",
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs task [--background] [--write] [--worktree] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs worktree-cleanup <job-id> --action <keep|discard> [--cwd <path>]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
Expand Down Expand Up @@ -429,6 +433,7 @@ async function executeReviewRun(request) {

async function executeTaskRun(request) {
const workspaceRoot = resolveWorkspaceRoot(request.cwd);
const stateRoot = request.stateRoot ? resolveWorkspaceRoot(request.stateRoot) : workspaceRoot;
ensureCodexReady(request.cwd);

const taskMetadata = buildTaskRunMetadata({
Expand All @@ -438,7 +443,7 @@ async function executeTaskRun(request) {

let resumeThreadId = null;
if (request.resumeLast) {
const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, {
const latestThread = await resolveLatestTrackedTaskThread(stateRoot, {
excludeJobId: request.jobId
});
if (!latestThread) {
Expand Down Expand Up @@ -498,6 +503,33 @@ async function executeTaskRun(request) {
};
}

async function executeTaskRunWithWorktree(request) {
ensureGitRepository(request.cwd);
const session = createWorktreeSession(request.cwd);

try {
const execution = await executeTaskRun({
...request,
cwd: session.worktreePath,
stateRoot: request.cwd
});

const diff = diffWorktreeSession(session);
const rendered = renderWorktreeTaskResult(execution, session, diff, { jobId: request.jobId });
return {
...execution,
rendered,
payload: {
...execution.payload,
worktreeSession: session
}
};
} catch (error) {
cleanupWorktreeSession(session, { keep: false });
throw error;
}
}

function buildReviewJobMetadata(reviewName, target) {
return {
kind: reviewName === "Adversarial Review" ? "adversarial-review" : "review",
Expand Down Expand Up @@ -570,13 +602,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) {
});
}

function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) {
function buildTaskRequest({ cwd, model, effort, prompt, write, worktree, resumeLast, jobId }) {
return {
cwd,
model,
effort,
prompt,
write,
worktree,
resumeLast,
jobId
};
Expand Down Expand Up @@ -704,7 +737,7 @@ async function handleReview(argv) {
async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
booleanOptions: ["json", "write", "worktree", "resume-last", "resume", "fresh", "background"],
aliasMap: {
m: "model"
}
Expand All @@ -722,11 +755,14 @@ async function handleTask(argv) {
throw new Error("Choose either --resume/--resume-last or --fresh.");
}
const write = Boolean(options.write);
const worktree = Boolean(options.worktree);
const taskMetadata = buildTaskRunMetadata({
prompt,
resumeLast
});

const runTask = write && worktree ? executeTaskRunWithWorktree : executeTaskRun;

if (options.background) {
ensureCodexReady(cwd);
requireTaskRequest(prompt, resumeLast);
Expand All @@ -738,6 +774,7 @@ async function handleTask(argv) {
effort,
prompt,
write,
worktree,
resumeLast,
jobId: job.id
});
Expand All @@ -750,12 +787,13 @@ async function handleTask(argv) {
await runForegroundCommand(
job,
(progress) =>
executeTaskRun({
runTask({
cwd,
model,
effort,
prompt,
write,
worktree,
resumeLast,
jobId: job.id,
onProgress: progress
Expand Down Expand Up @@ -794,14 +832,15 @@ async function handleTaskWorker(argv) {
logFile: storedJob.logFile ?? null
}
);
const runTask = request.write && request.worktree ? executeTaskRunWithWorktree : executeTaskRun;
await runTrackedJob(
{
...storedJob,
workspaceRoot,
logFile
},
() =>
executeTaskRun({
runTask({
...request,
onProgress: progress
}),
Expand Down Expand Up @@ -958,6 +997,39 @@ async function handleCancel(argv) {
outputCommandResult(payload, renderCancelReport(nextJob), options.json);
}

function handleWorktreeCleanup(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["action", "cwd"],
booleanOptions: ["json"]
});

const action = options.action;
if (action !== "keep" && action !== "discard") {
throw new Error("Required: --action keep or --action discard.");
}

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const jobId = positionals[0];
if (!jobId) {
throw new Error("Required: job ID as positional argument.");
}

const storedJob = readStoredJob(workspaceRoot, jobId);
if (!storedJob) {
throw new Error(`No stored job found for ${jobId}.`);
}

const session = storedJob.result?.worktreeSession ?? storedJob.payload?.worktreeSession;
if (!session || !session.worktreePath || !session.branch || !session.repoRoot) {
throw new Error(`Job ${jobId} does not have worktree session data. Was it run with --worktree?`);
}

const result = cleanupWorktreeSession(session, { keep: action === "keep" });
const rendered = renderWorktreeCleanupResult(action, result, session);
outputCommandResult({ jobId, action, result, session }, rendered, options.json);
Comment on lines +1028 to +1030
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return failure exit code when keep apply fails

The worktree-cleanup handler always exits successfully after rendering output, even when --action keep fails to apply the patch (result.applied === false with an error detail). In that conflict path the command reports a failure message but still returns exit code 0, so wrappers/automation cannot distinguish a successful keep from a preserved-worktree failure and may continue under a false success state. This should set a non-zero exit code for failed keep applies (while still allowing the explicit no-op No changes to apply. case to remain successful).

Useful? React with 👍 / 👎.

}

async function main() {
const [subcommand, ...argv] = process.argv.slice(2);
if (!subcommand || subcommand === "help" || subcommand === "--help") {
Expand Down Expand Up @@ -995,6 +1067,9 @@ async function main() {
case "cancel":
await handleCancel(argv);
break;
case "worktree-cleanup":
handleWorktreeCleanup(argv);
break;
default:
throw new Error(`Unknown subcommand: ${subcommand}`);
}
Expand Down
67 changes: 67 additions & 0 deletions plugins/codex/scripts/lib/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,73 @@ function collectBranchContext(cwd, baseRef) {
};
}

export function createWorktree(repoRoot) {
const ts = Date.now();
const worktreesDir = path.join(repoRoot, ".worktrees");
fs.mkdirSync(worktreesDir, { recursive: true });
Comment on lines +192 to +193
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store worktrees outside the repository root

Creating the worktree under repoRoot/.worktrees makes the main checkout immediately dirty (.worktrees/.../ becomes untracked) while the rescue task is active. That interacts badly with working-tree review flows: auto-scope sees untracked content and collectWorkingTreeContext tries to read untracked paths as files, which crashes on this directory entry (EISDIR) and breaks /codex:review until cleanup. Using an external temp location (or otherwise avoiding untracked directories in the repo root) avoids this regression.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dirty-state concern is valid and we've addressed it, but we can't move worktrees outside the repo root. Claude Code's permission sandbox is scoped to the project directory — a worktree at an external path (e.g. /tmp/...) triggers an auth/permission prompt on every single tool use (Read, Edit, Write, Bash), making the feature unusable.

Instead, createWorktree now writes .worktrees/ to the target repo's .git/info/exclude (the per-repo gitignore that doesn't touch tracked files). This prevents the untracked-directory issue without modifying the user's .gitignore or requiring worktrees to live outside the sandbox boundary.


// Ensure .worktrees/ is excluded from the target repo without modifying tracked files.
// Use git rev-parse to resolve the real git dir (handles linked worktrees where .git is a file).
const rawGitDir = gitChecked(repoRoot, ["rev-parse", "--git-dir"]).stdout.trim();
const gitDir = path.resolve(repoRoot, rawGitDir);
const excludePath = path.join(gitDir, "info", "exclude");
const excludeContent = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, "utf8") : "";
if (!excludeContent.includes(".worktrees")) {
fs.mkdirSync(path.dirname(excludePath), { recursive: true });
fs.appendFileSync(excludePath, `${excludeContent.endsWith("\n") || !excludeContent ? "" : "\n"}.worktrees/\n`);
}

const worktreePath = path.join(worktreesDir, `codex-${ts}`);
const branch = `codex/${ts}`;
const baseCommit = gitChecked(repoRoot, ["rev-parse", "HEAD"]).stdout.trim();
gitChecked(repoRoot, ["worktree", "add", worktreePath, "-b", branch]);
return { worktreePath, branch, repoRoot, baseCommit, timestamp: ts };
}

export function removeWorktree(repoRoot, worktreePath) {
const result = git(repoRoot, ["worktree", "remove", "--force", worktreePath]);
if (result.status !== 0 && !result.stderr.includes("is not a working tree")) {
throw new Error(`Failed to remove worktree: ${result.stderr.trim()}`);
}
}

export function deleteWorktreeBranch(repoRoot, branch) {
git(repoRoot, ["branch", "-D", branch]);
}

export function getWorktreeDiff(worktreePath, baseCommit) {
git(worktreePath, ["add", "-A"]);
const result = git(worktreePath, ["diff", "--cached", baseCommit, "--stat"]);
if (result.status !== 0 || !result.stdout.trim()) {
return { stat: "", patch: "" };
}
const stat = result.stdout.trim();
const patchResult = gitChecked(worktreePath, ["diff", "--cached", baseCommit]);
return { stat, patch: patchResult.stdout };
}

export function applyWorktreePatch(repoRoot, worktreePath, baseCommit) {
git(worktreePath, ["add", "-A"]);
const patchResult = git(worktreePath, ["diff", "--cached", baseCommit]);
if (patchResult.status !== 0 || !patchResult.stdout.trim()) {
return { applied: false, detail: "No changes to apply." };
}
const patchPath = path.join(
repoRoot,
".codex-worktree-" + Date.now() + "-" + Math.random().toString(16).slice(2) + ".patch"
);
try {
fs.writeFileSync(patchPath, patchResult.stdout, "utf8");
const applyResult = git(repoRoot, ["apply", "--index", patchPath]);
if (applyResult.status !== 0) {
return { applied: false, detail: applyResult.stderr.trim() || "Patch apply failed (conflicts?)." };
}
return { applied: true, detail: "Changes applied and staged." };
} finally {
fs.rmSync(patchPath, { force: true });
}
}

export function collectReviewContext(cwd, target) {
const repoRoot = getRepoRoot(cwd);
const state = getWorkingTreeState(cwd);
Expand Down
4 changes: 3 additions & 1 deletion plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ export function runCommand(command, args = [], options = {}) {
windowsHide: true
});

const succeeded = result.status === 0 && result.signal === null;

return {
command,
args,
status: result.status ?? 0,
signal: result.signal ?? null,
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
error: result.error ?? null
error: succeeded ? null : result.error ?? null
};
}

Expand Down
60 changes: 60 additions & 0 deletions plugins/codex/scripts/lib/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,66 @@ export function renderStoredJobResult(job, storedJob) {
return `${lines.join("\n").trimEnd()}\n`;
}

export function renderWorktreeTaskResult(execution, session, diff, { jobId = null } = {}) {
const lines = [];

if (execution.rendered) {
lines.push(execution.rendered.trimEnd());
lines.push("");
}

lines.push("---");
lines.push("");
lines.push("## Worktree");
lines.push("");
lines.push(`Branch: \`${session.branch}\``);
lines.push(`Path: \`${session.worktreePath}\``);
lines.push("");

if (diff.stat) {
lines.push("### Changes");
lines.push("");
lines.push("```");
lines.push(diff.stat);
lines.push("```");
lines.push("");
} else {
lines.push("Codex made no file changes in the worktree.");
lines.push("");
}

lines.push("### Actions");
lines.push("");
lines.push("Apply these changes to your working tree, or discard them:");
lines.push("");
const resolvedJobId = jobId ?? "JOB_ID";
const cwdFlag = session.repoRoot ? ` --cwd "${session.repoRoot}"` : "";
lines.push(`- **Keep**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action keep${cwdFlag}\``);
lines.push(`- **Discard**: \`node "\${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" worktree-cleanup ${resolvedJobId} --action discard${cwdFlag}\``);

return `${lines.join("\n").trimEnd()}\n`;
}

export function renderWorktreeCleanupResult(action, result, session) {
const lines = ["# Worktree Cleanup", ""];

if (action === "keep") {
if (result.applied) {
lines.push(`Applied changes from \`${session.branch}\` and cleaned up.`);
} else if (result.detail === "No changes to apply.") {
lines.push(`No changes to apply. Worktree and branch \`${session.branch}\` cleaned up.`);
} else {
lines.push(`Failed to apply changes: ${result.detail}`);
lines.push("");
lines.push(`The worktree and branch \`${session.branch}\` have been preserved at \`${session.worktreePath}\` for manual recovery.`);
}
Comment on lines +496 to +500
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat no-op keep cleanup as success in rendered output

When keep has no diff, cleanupWorktreeSession now correctly deletes the worktree/branch but still returns { applied: false, detail: "No changes to apply." }; this renderer branch interprets every applied:false as a failed apply and says the worktree was preserved. That produces incorrect operator guidance (it tells users to recover from a path that was already removed), so the no-op keep path should be rendered as a successful cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in peterdrier/codex-plugin-cc@92a3126 — added an explicit branch for detail === "No changes to apply." that renders as a successful cleanup instead of the misleading "failed/preserved" message.

} else {
lines.push(`Discarded worktree \`${session.worktreePath}\` and branch \`${session.branch}\`.`);
}

return `${lines.join("\n").trimEnd()}\n`;
}

export function renderCancelReport(job) {
const lines = [
"# Codex Cancel",
Expand Down
Loading