Skip to content
Merged
6 changes: 3 additions & 3 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
},
"metadata": {
"description": "Claude Code plugins that delegate to local coding CLIs.",
"version": "1.0.8"
"version": "1.0.9"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.8",
"version": "1.0.9",
"author": {
"name": "OpenAI"
},
Expand All @@ -20,7 +20,7 @@
{
"name": "grok",
"description": "Use a local Grok Build CLI for read-only questions, reviews, and rescue tasks.",
"version": "0.2.0",
"version": "0.3.0",
"author": {
"name": "External CLI Companions"
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "1.0.8",
"version": "1.0.9",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
Expand Down
8 changes: 8 additions & 0 deletions plugins/codex/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 1.0.9

- Job state is keyed by the shared main-repo root, so jobs launched from linked git worktrees are visible to `/codex:status`, `/codex:result`, and `/codex:cancel` from any checkout. Legacy worktree-keyed state is read-merged without being modified; plain (non-worktree) repos keep their exact previous state key.
- `/codex:status` shows "last activity Xm ago" for running jobs (derived from the job log) and adds an advisory once a running job has produced no new events for 5+ minutes, replacing blind periodic polling.
- Write-enabled task runs capture a git before/after write summary (changed files, or an explicit "no edits landed" note) rendered after the final output — independent evidence of what actually changed.
- Model-fallback regression coverage now spans the task and adversarial-review paths, including the fallback-also-rejected and same-model-guard cases.
- `codex-rescue` agent hardening: scope rules (report only, commit nothing, one writer per worktree, `--cwd` forwarding for foreign checkouts) and a failure contract that returns companion stdout/stderr verbatim on non-zero exit instead of returning nothing.

## 1.0.8

- Failed reviews now surface the underlying Codex error (e.g. model-rejected 400s) in the rendered output instead of a bare "Reviewer failed to output a response".
Expand Down
9 changes: 8 additions & 1 deletion plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ Forwarding rules:
- Otherwise forward the task as a fresh `task` run.
- Preserve the user's task text as-is apart from stripping routing flags.
- Return the stdout of the `codex-companion` command exactly as-is.
- If the Bash call fails or Codex cannot be invoked, return nothing.
- Always return the companion's stdout and stderr verbatim, including when the command exits non-zero (a non-zero exit may be an "incomplete" job whose warning must reach the caller). Return nothing only when the companion produced no output at all.

Scope rules:

- Report only; commit nothing.
- Do not stage, reset, or checkout.
- One writer per worktree.
- If the task text names a checkout/worktree path different from the invocation directory, forward it with `--cwd <that path>` so the work lands in that checkout instead of the invocation directory.

Response style:

Expand Down
33 changes: 27 additions & 6 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@ import {
} from "./lib/codex.mjs";
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
import {
captureWriteGitStatus,
collectReviewContext,
ensureGitRepository,
resolveReviewTarget,
summarizeWriteChanges
} from "./lib/git.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import {
Expand Down Expand Up @@ -553,13 +559,17 @@ async function executeTaskRun(request) {
throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
}

const writeEnabled = Boolean(request.write);
const gitStatusCwd = request.cwd ?? workspaceRoot;
const beforeGitStatus = writeEnabled ? captureWriteGitStatus(gitStatusCwd) : null;

const taskTurnOptions = {
resumeThreadId,
prompt: request.prompt,
defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "",
model: request.model,
effort: request.effort,
sandbox: request.write ? "workspace-write" : "read-only",
sandbox: writeEnabled ? "workspace-write" : "read-only",
onProgress: request.onProgress,
persistThread: true,
threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT)
Expand All @@ -573,6 +583,14 @@ async function executeTaskRun(request) {
fallbackModelUsed = fallbackModel;
}

let writeSummary = null;
if (beforeGitStatus) {
const afterGitStatus = captureWriteGitStatus(gitStatusCwd);
if (afterGitStatus) {
writeSummary = summarizeWriteChanges(beforeGitStatus, afterGitStatus);
}
}

const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : "";
const failureMessage = result.error?.message ?? result.stderr ?? "";
const rendered = renderTaskResult(
Expand All @@ -584,8 +602,9 @@ async function executeTaskRun(request) {
{
title: taskMetadata.title,
jobId: request.jobId ?? null,
write: Boolean(request.write),
fallbackModel: fallbackModelUsed
write: writeEnabled,
fallbackModel: fallbackModelUsed,
writeSummary
}
);
const payload = {
Expand All @@ -595,7 +614,8 @@ async function executeTaskRun(request) {
error: result.error?.message ?? null,
fallbackModel: fallbackModelUsed,
touchedFiles: result.touchedFiles,
reasoningSummary: result.reasoningSummary
reasoningSummary: result.reasoningSummary,
writeSummary
};

return {
Expand All @@ -607,7 +627,8 @@ async function executeTaskRun(request) {
summary: firstMeaningfulLine(rawOutput, firstMeaningfulLine(failureMessage, `${taskMetadata.title} finished.`)),
jobTitle: taskMetadata.title,
jobClass: "task",
write: Boolean(request.write)
write: writeEnabled,
writeSummary
};
}

Expand Down
107 changes: 107 additions & 0 deletions plugins/codex/scripts/lib/git.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,53 @@ export function getRepoRoot(cwd) {
return gitChecked(cwd, ["rev-parse", "--show-toplevel"]).stdout.trim();
}

/**
* Shared repo root across linked worktrees (dirname of git-common-dir).
* Matches getRepoRoot on a normal checkout; from a linked worktree returns the main root.
* Used for companion state/job visibility — not for review diff context.
*/
export function getSharedRepoRoot(cwd) {
const resolvedCwd = path.resolve(cwd);

const bareResult = git(resolvedCwd, ["rev-parse", "--is-bare-repository"]);
const bareErrorCode = bareResult.error && "code" in bareResult.error ? bareResult.error.code : null;
if (bareErrorCode === "ENOENT") {
throw new Error("git is not installed. Install Git and retry.");
}
if (bareResult.status !== 0) {
throw new Error("This command must run inside a Git repository.");
}
if (bareResult.stdout.trim() === "true") {
try {
return getRepoRoot(resolvedCwd);
} catch {
return resolvedCwd;
}
}

const absoluteCommon = git(resolvedCwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
if (absoluteCommon.status === 0) {
const commonDir = absoluteCommon.stdout.trim();
try {
return path.dirname(fs.realpathSync.native(commonDir));
} catch {
return path.dirname(path.resolve(commonDir));
}
}

// Older git without --path-format=absolute
const plainCommon = git(resolvedCwd, ["rev-parse", "--git-common-dir"]);
if (plainCommon.status !== 0) {
throw new Error("This command must run inside a Git repository.");
}
const resolvedCommon = path.resolve(resolvedCwd, plainCommon.stdout.trim());
try {
return path.dirname(fs.realpathSync.native(resolvedCommon));
} catch {
return path.dirname(resolvedCommon);
}
}

export function detectDefaultBranch(cwd) {
const symbolic = git(cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"]);
if (symbolic.status === 0) {
Expand Down Expand Up @@ -132,6 +179,66 @@ export function getWorkingTreeState(cwd) {
};
}

/**
* Capture paths that differ from HEAD plus untracked files.
* Returns null when cwd is not a Git work tree (write-summary callers skip silently).
* @param {string} cwd
* @returns {Set<string> | null}
*/
export function captureWriteGitStatus(cwd) {
const inside = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
if (inside.error || inside.status !== 0 || inside.stdout.trim() !== "true") {
return null;
}

const paths = new Set();
const hasHead = git(cwd, ["rev-parse", "--verify", "HEAD"]);
if (hasHead.status === 0) {
const diff = git(cwd, ["diff", "--name-only", "HEAD", "-z"]);
if (diff.error) {
throw diff.error;
}
if (diff.status !== 0) {
throw new Error(diff.stderr.trim() || "Unable to capture Git diff for this write task.");
}
for (const entry of String(diff.stdout ?? "").split("\0")) {
if (entry) {
paths.add(entry);
}
}
}

const untracked = git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
if (untracked.error) {
throw untracked.error;
}
if (untracked.status !== 0) {
throw new Error(untracked.stderr.trim() || "Unable to capture untracked files for this write task.");
}
for (const entry of String(untracked.stdout ?? "").split("\0")) {
if (entry) {
paths.add(entry);
}
}
return paths;
}

/**
* @param {Set<string>} before
* @param {Set<string>} after
*/
export function summarizeWriteChanges(before, after) {
const changedFiles = [
...[...after].filter((filePath) => !before.has(filePath)),
...[...before].filter((filePath) => !after.has(filePath))
].sort();
return {
before: [...before].sort(),
after: [...after].sort(),
changedFiles
};
}

export function resolveReviewTarget(cwd, options = {}) {
ensureGitRepository(cwd);

Expand Down
17 changes: 17 additions & 0 deletions plugins/codex/scripts/lib/job-control.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ function formatElapsedDuration(startValue, endValue = null) {
return `${seconds}s`;
}

function resolveLastActivityAt(job) {
try {
if (job.logFile) {
return fs.statSync(job.logFile).mtime.toISOString();
}
} catch {
// Missing log or stat failure — fall through to timestamps.
}
return job.startedAt ?? job.createdAt ?? null;
}

function looksLikeVerificationCommand(line) {
return /\b(test|tests|lint|build|typecheck|type-check|check|verify|validate|pytest|jest|vitest|cargo test|npm test|pnpm test|yarn test|go test|mvn test|gradle test|tsc|eslint|ruff)\b/i.test(
line
Expand Down Expand Up @@ -174,6 +185,12 @@ export function enrichJob(job, options = {}) {
: null
};

if (job.status === "queued" || job.status === "running") {
const lastActivityAt = resolveLastActivityAt(job);
enriched.lastActivityAt = lastActivityAt;
enriched.inactiveFor = lastActivityAt ? formatElapsedDuration(lastActivityAt) : null;
}

return {
...enriched,
phase: enriched.phase ?? inferLegacyJobPhase(enriched, enriched.progressPreview)
Expand Down
Loading