Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cc",
"version": "1.4.1",
"version": "1.4.2",
"description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.",
"author": {
"name": "Sendbird, Inc.",
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v1.4.2

- Refuse companion delegation from Codex threads that are themselves driven by Claude Code. The reverse-direction plugin (Claude Code → Codex) spawns a bare `codex app-server` that inherits `~/.codex`, so its headless review threads see this plugin's skills and delegated the review back to Claude Code — looping the work between the two assistants and burning minutes on `wait`-tool spins with narration in place of findings. Session hooks now stamp `hostOrigin: "claude-code"` on the current-session marker when Claude Code host env markers (`CLAUDECODE` / `CLAUDE_CODE_ENTRYPOINT`) reach them, and `review`, `adversarial-review`, and `task` refuse delegation from such threads with explicit instructions to perform the work directly in that thread. Interactive sessions (env session id present), background forwarding children owned by a different session, and unstamped state all stay open, so the gate fails open everywhere the loop cannot occur.
- Stop naming `AskUserQuestion` — a Claude Code tool that Codex does not have — in the review skills' execution-mode ask. Codex's own `request_user_input` is gated behind `[tools] experimental_request_user_input` and does not exist in non-interactive threads, so the skills now use a question tool only when the thread actually has one, ask inline when a user is reading, and proceed with the recommended mode in headless threads instead of spinning on a collaboration tool looking for a picker that cannot appear.
- Skip the turn-end review gate when no turn baseline was recorded for the session. The baseline is written on `UserPromptSubmit`, so its absence means no user prompt drove the session — for example an externally hosted headless thread — and there is no turn to review. Previously the gate treated a missing baseline as a signal to run the full Claude review.

## v1.4.1

- Stop defaulting reasoning effort per model. v1.4.0 gave every friendly alias a `high` default, which duplicated a catalog that belongs to the host CLI — exactly like the pinned model IDs removed in that same release — and was wrong for `haiku`, since Haiku 4.5 is not in the reasoning-effort model tier. `--effort` is now forwarded only when you pass it, so each model keeps whatever effort Claude Code defaults to and Claude Code stays the authority on which levels a model supports. `--model` still defaults to `opus`. Users who relied on the v1.2.0 `opus` + `xhigh` behavior should pass `--effort xhigh` explicitly.
Expand Down
22 changes: 22 additions & 0 deletions hooks/lib/host-origin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright 2026 Sendbird, Inc.
* SPDX-License-Identifier: Apache-2.0
*/
import process from "node:process";

/**
* Detect whether this Codex session is hosted by an external assistant rather
* than a user-driven Codex frontend. A Codex app-server spawned from inside
* Claude Code inherits the Claude Code process env (measured: CLAUDECODE=1 /
* CLAUDE_CODE_ENTRYPOINT reach plugin hooks). Threads in such an app-server
* are host-driven, so companion delegation must not loop back to Claude Code.
*
* Every writer of the current-session marker must stamp this, or a later
* rewrite would erase the origin and reopen the delegation loop.
*/
export function detectExternalHostOrigin() {
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT) {
return "claude-code";
}
return null;
}
5 changes: 4 additions & 1 deletion hooks/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import process from "node:process";
import { fileURLToPath } from "node:url";

import { readHookInput } from "./lib/hook-input.mjs";
import { detectExternalHostOrigin } from "./lib/host-origin.mjs";
import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
import { setCurrentSession } from "../scripts/lib/state.mjs";
import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
Expand Down Expand Up @@ -61,7 +62,9 @@ function handleSessionStart(input) {
// Forward plugin data dir if set
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
if (input.session_id && !nestedSession) {
setCurrentSession(cwd, input.session_id);
setCurrentSession(cwd, input.session_id, {
hostOrigin: detectExternalHostOrigin(),
});
}
}

Expand Down
14 changes: 11 additions & 3 deletions hooks/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const SKIP_INTERACTIVE_HOOKS_ENV = "CLAUDE_COMPANION_SKIP_INTERACTIVE_HOOKS";
const STOP_REVIEW_SUCCESS_NOTE = "Claude Code turn-end review passed.";
const STOP_REVIEW_NO_EDIT_NOTE =
"Claude Code turn-end review skipped: the most recent turn made no net edits.";
const STOP_REVIEW_NO_BASELINE_NOTE =
"Claude Code turn-end review skipped: no user turn was recorded for this Codex session.";
const MAX_INLINE_REASON_CHARS = 1_500;

function emitDecision(payload) {
Expand Down Expand Up @@ -290,8 +292,14 @@ function evaluateTurnEditGate(cwd, workspaceRoot, sessionId) {

const baseline = readTurnBaseline(workspaceRoot, sessionId);
if (!baseline?.fingerprint) {
// The baseline is written by UserPromptSubmit, so its absence means no user
// prompt drove this Codex session and there is no turn to review. Reachable
// when another host drives Codex headlessly, e.g. a Claude Code review
// thread that inherits this plugin.
return {
shouldSkipReview: false,
shouldSkipReview: true,
skipStatus: "skipped_no_turn_baseline",
skipNote: STOP_REVIEW_NO_BASELINE_NOTE,
reason: "No turn baseline was recorded for this session.",
Comment on lines 299 to 303

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 Preserve review enforcement when baseline capture fails

If UserPromptSubmit ran but captureTurnBaseline() failed—for example because fingerprinting encountered a transient Git/filesystem error—the hook catches that error and leaves no baseline. This branch now treats the missing baseline as proof that no prompt occurred and silently skips an enabled turn-end review, whereas the previous behavior ran the review in this case. Distinguish an explicitly headless session from a baseline-capture failure instead of failing open for every absent or unreadable baseline.

Useful? React with 👍 / 👎.

baseline,
current: null,
Expand Down Expand Up @@ -393,13 +401,13 @@ async function main() {
};
if (turnEditGate.shouldSkipReview) {
persistFinal({
status: "skipped_no_turn_edits",
status: turnEditGate.skipStatus ?? "skipped_no_turn_edits",
reason: turnEditGate.reason,
claudeInvoked: false,
runningTaskNote,
...fingerprintFields,
});
logNote(STOP_REVIEW_NO_EDIT_NOTE);
logNote(turnEditGate.skipNote ?? STOP_REVIEW_NO_EDIT_NOTE);
logNote(runningTaskNote);
return;
}
Expand Down
8 changes: 6 additions & 2 deletions hooks/unread-result-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";

import { readHookInput } from "./lib/hook-input.mjs";
import { detectExternalHostOrigin } from "./lib/host-origin.mjs";
import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
import {
getConfig,
Expand Down Expand Up @@ -112,7 +113,8 @@ function captureTurnBaseline(workspaceRoot, sessionId, cwd) {
fingerprint,
});
} catch {
// Baseline capture is best-effort. If it fails, Stop falls back to running review.
// Baseline capture is best-effort. If it fails, Stop skips the review for
// this turn rather than reviewing a turn it cannot delimit.
}
}

Expand All @@ -134,7 +136,9 @@ async function main() {
}

try {
setCurrentSession(workspaceRoot, sessionId);
setCurrentSession(workspaceRoot, sessionId, {
hostOrigin: detectExternalHostOrigin(),
});
} catch {
// Best effort only: an invalid session id should not fail a user prompt.
}
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": "cc-plugin-codex",
"version": "1.4.1",
"version": "1.4.2",
"description": "Claude Code Plugin for Codex by Sendbird",
"type": "module",
"author": {
Expand Down
33 changes: 33 additions & 0 deletions scripts/claude-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
generateJobId,
getConfig,
getCurrentSession,
getCurrentSessionMarker,
listJobs,
patchJob,
JOB_RESERVATION_SUFFIX,
Expand Down Expand Up @@ -246,6 +247,36 @@ function alignCurrentSessionToOwner(workspaceRoot, ownerSessionId) {
setCurrentSession(workspaceRoot, ownerSessionId);
}

/**
* Refuse delegation from a Codex thread that is itself driven by an external
* host (e.g. a headless review thread spawned by Claude Code). Delegating back
* to Claude Code from there loops the work between the two assistants.
*
* Interactive Codex sessions receive SESSION_ID_ENV through the session hook's
* env-file export; externally hosted app-server threads do not (measured), so
* an absent env session id plus a `hostOrigin` stamp on the current-session
* marker identifies the loop. Fail-open everywhere else.
*/
function assertDelegationAllowed(workspaceRoot, ownerSessionId, workLabel) {
if (process.env[SESSION_ID_ENV]) {
return;
}
const marker = getCurrentSessionMarker(workspaceRoot);
if (!marker || marker.hostOrigin !== "claude-code") {
return;
Comment on lines +264 to +266

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 Scope host-origin markers to the invoking session

When a Claude-driven thread and a normal Codex session are open in the same workspace, both hooks overwrite the same workspace-global current-session.json; the normal session writes an unstamped marker. Because the Claude-driven companion lacks SESSION_ID_ENV, this check can then read the other session's marker and return, allowing delegation back to Claude Code and recreating the loop this change is intended to prevent. Store and resolve origin per session/thread at the shared state boundary rather than treating the workspace's last writer as the caller.

AGENTS.md reference: AGENTS.md:L4-L4

Useful? React with 👍 / 👎.

}
if (ownerSessionId && ownerSessionId !== marker.sessionId) {
return;
}
throw new Error(
[
`This Codex thread is driven by Claude Code, not by a user prompt, so delegating this ${workLabel} back to Claude Code would loop it between the two assistants.`,
`Do not retry this command and do not look for another way to reach Claude Code.`,
`Perform the requested ${workLabel} yourself in this thread and present your own findings directly.`,
].join("\n")
);
}

async function withReleasedReservation(workspaceRoot, explicitJobId, fn) {
try {
return await fn();
Expand Down Expand Up @@ -1515,6 +1546,7 @@ async function handleReviewCommand(argv, config) {
await withReleasedReservation(workspaceRoot, explicitJobId, async () => {
// Validate inside the reservation guard so failures do not leak markers.
config.validateRequest?.(target, focusText);
assertDelegationAllowed(workspaceRoot, ownerSessionId, "review");
const metadata = buildReviewJobMetadata(config.reviewName, target);
alignCurrentSessionToOwner(workspaceRoot, ownerSessionId);

Expand Down Expand Up @@ -1634,6 +1666,7 @@ async function handleTask(argv) {
const ownerSessionId = resolveOwnerSessionId(options["owner-session-id"]);
const explicitJobId = resolveExplicitJobId(options["job-id"], workspaceRoot);
await withReleasedReservation(workspaceRoot, explicitJobId, async () => {
assertDelegationAllowed(workspaceRoot, ownerSessionId, "task");
const taskMetadata = buildTaskRunMetadata({
prompt,
resumeLast
Expand Down
23 changes: 20 additions & 3 deletions scripts/lib/state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -267,16 +267,17 @@ export function getConfig(cwd) {
// Current session marker (fallback when Codex does not propagate env vars)
// ---------------------------------------------------------------------------

export function setCurrentSession(cwd, sessionId) {
export function setCurrentSession(cwd, sessionId, options = {}) {
sanitizeId(sessionId, "session ID");
ensureStateDir(cwd);
writeAtomic(resolveCurrentSessionFile(cwd), {
sessionId,
...(options.hostOrigin ? { hostOrigin: String(options.hostOrigin) } : {}),
updatedAt: nowIso(),
});
}

export function getCurrentSession(cwd) {
function readCurrentSessionPayload(cwd) {
const filePath = resolveCurrentSessionFile(cwd);
try {
const payload = JSON.parse(fs.readFileSync(filePath, "utf8"));
Expand All @@ -290,12 +291,28 @@ export function getCurrentSession(cwd) {
fs.unlinkSync(filePath);
return null;
}
return sanitizeId(payload.sessionId, "session ID");
sanitizeId(payload.sessionId, "session ID");
return payload;
} catch {
return null;
}
}

export function getCurrentSession(cwd) {
return readCurrentSessionPayload(cwd)?.sessionId ?? null;
}

export function getCurrentSessionMarker(cwd) {
const payload = readCurrentSessionPayload(cwd);
if (!payload) {
return null;
}
return {
sessionId: payload.sessionId,
hostOrigin: typeof payload.hostOrigin === "string" ? payload.hostOrigin : null,
};
}

export function clearCurrentSession(cwd, sessionId = null) {
const filePath = resolveCurrentSessionFile(cwd);
if (sessionId != null) {
Expand Down
3 changes: 2 additions & 1 deletion skills/adversarial-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ Execution mode rules:
- Recommend waiting only when the scoped review is clearly tiny, roughly 1-2 files total and no sign of a broader directory-sized change.
- In every other case, including unclear size, recommend background.
- When in doubt, run the review instead of declaring that there is nothing to review.
- Then use `AskUserQuestion` exactly once with two options, putting the recommended option first and suffixing its label with `(Recommended)`:
- Then ask the user once which execution mode to use, offering two options with the recommended one first and its label suffixed `(Recommended)`:
- `Wait for results`
- `Run in background`
- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` only behind `[tools] experimental_request_user_input`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have.

Argument handling:
- Preserve the user's arguments exactly.
Expand Down
3 changes: 2 additions & 1 deletion skills/review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ Execution mode rules:
- Recommend waiting only when the review is clearly tiny, roughly 1-2 files total and no sign of a broader directory-sized change.
- In every other case, including unclear size, recommend background.
- When in doubt, run the review instead of declaring that there is nothing to review.
- Then use `AskUserQuestion` exactly once with two options, putting the recommended option first and suffixing its label with `(Recommended)`:
- Then ask the user once which execution mode to use, offering two options with the recommended one first and its label suffixed `(Recommended)`:
- `Wait for results`
- `Run in background`
- Use a question tool for that ask only when this thread actually has one. Codex exposes `request_user_input` only behind `[tools] experimental_request_user_input`, and it does not exist in non-interactive threads. If you have no question tool but a user is reading this thread, ask in your own reply and stop there. In a non-interactive thread with no user to answer, skip the ask and proceed with the recommended mode. Never spin on a wait or collaboration tool looking for a picker this thread does not have.

Argument handling:
- Preserve the user's arguments exactly.
Expand Down
Loading