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.2.1",
"version": "1.3.0",
"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
13 changes: 9 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# Changelog

## Unreleased

- Send Claude prompts through stdin instead of command-line arguments so large working-tree reviews no longer fail with `spawn ENAMETOOLONG` on Windows, and fail closed if prompt delivery itself errors.
- Resolve native `claude.exe` shims and npm-packaged executables from the active Windows `PATH`, npm prefix, or `APPDATA`, while retaining an explicit `CC_PLUGIN_CODEX_CLAUDE_BIN` override.
## v1.3.0

- Preserve the originating workspace when reserved background job ids pass through built-in rescue, review, and adversarial-review forwarding children.
- Store plugin state under Codex's injected marketplace-qualified `PLUGIN_DATA` root, migrate the legacy `cc` and `claude-code` namespaces, and configure the destination plus migration roots for sandboxed writes. Existing sessions receive explicit restart-and-rerun guidance before review-gate changes continue, and uninstall removes the plugin-specific writable-root grants.
- Harden the turn-end review gate with the bundled read-only git MCP server, Claude-compatible Draft-07 structured output, and bounded inline block reasons while preserving full diagnostics in snapshots.
- Remove the non-dispatched `SessionEnd` hook and reap stale background jobs safely during `UserPromptSubmit` without cancelling live work.
- Send Claude prompts through stdin and resolve native Windows/npm Claude executables, including working-tree reviews larger than the Windows command-line limit.
- Bound aggregate untracked review context and retain detached worker diagnostics in managed job logs.
- Refresh marketplace installation guidance, repository agent instructions, GitHub Actions, TypeScript, ESLint, Node type definitions, and runtime globals.

## v1.2.1

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ $cc:setup
```

All checks should pass. If any fail, `$cc:setup` tells you what to fix.
If setup adds the marketplace-qualified plugin-data root and legacy migration roots to the writable-root list, restart Codex and rerun the same command; a requested review-gate toggle waits for that restart.

### 3. Try It

Expand Down Expand Up @@ -221,7 +222,7 @@ $cc:setup --disable-review-gate # turn it off
```

Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. If Claude Code isn't installed, it offers to install it.
This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, then trusts this plugin's current native hook hashes so Codex loads the bundled hooks from the active plugin cache.
This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, trusts this plugin's current native hook hashes, and allows sandboxed writes to Codex's injected marketplace-qualified plugin-data root plus the legacy roots needed for one-time migration. If those writable roots were just added, restart Codex and rerun setup before changing the review gate.

## Background Jobs

Expand Down
12 changes: 0 additions & 12 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,6 @@
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"$PLUGIN_ROOT/hooks/session-lifecycle-hook.mjs\" SessionEnd",
"statusMessage": "Cleaning up Claude Code bridge jobs"
}
]
}
],
"Stop": [
{
"hooks": [
Expand Down
99 changes: 2 additions & 97 deletions hooks/session-lifecycle-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
*
* SessionStart: Exports CLAUDE_COMPANION_SESSION_ID via CLAUDE_ENV_FILE.
*
* SessionEnd: Cleans up tracked jobs for the session, kills orphan `claude` processes.
*
* No broker lifecycle — Claude Code uses direct CLI invocation.
*/

Expand All @@ -22,17 +20,8 @@ import { fileURLToPath } from "node:url";

import { readHookInput } from "./lib/hook-input.mjs";
import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
import { terminateProcessTree, validateProcessIdentity } from "../scripts/lib/process.mjs";
import {
ACTIVE_JOB_STATUSES,
clearCurrentSession,
getCurrentSession,
listStoredJobs,
setCurrentSession,
transitionJob,
} from "../scripts/lib/state.mjs";
import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs";
import { setCurrentSession } from "../scripts/lib/state.mjs";
import { SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";

export { SESSION_ID_ENV };
const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA";
Expand Down Expand Up @@ -63,67 +52,6 @@ function isNestedCodexSession(inputSessionId) {
);
}

// ---------------------------------------------------------------------------
// Session cleanup
// ---------------------------------------------------------------------------

function cleanupSessionJobs(cwd, sessionId) {
if (!cwd || !sessionId) {
return;
}

const workspaceRoot = resolveWorkspaceRoot(cwd);
const jobs = listStoredJobs(workspaceRoot);
const sessionJobs = jobs.filter((job) => job.sessionId === sessionId);
if (sessionJobs.length === 0) {
return;
}

for (const job of sessionJobs) {
const stillRunning = ACTIVE_JOB_STATUSES.has(job.status);
if (!stillRunning) {
continue;
}
const hasPid = Number.isFinite(job.pid);
const hasTrustedPid =
hasPid &&
typeof job.pidIdentity === "string" &&
job.pidIdentity &&
validateProcessIdentity(job.pid, job.pidIdentity);
const canSafelyCancel = !hasPid || hasTrustedPid;
try {
if (hasTrustedPid) {
terminateProcessTree(job.pid);
}
} catch {
// Ignore teardown failures during session shutdown.
}
try {
transitionJob(
workspaceRoot,
job.id,
[job.status],
canSafelyCancel ? "cancelled" : "cancel_failed",
{
completedAt: nowIso(),
errorMessage: canSafelyCancel
? "Cancelled when the Codex session ended."
: "Refused to terminate a stored process without a matching PID identity.",
pid: canSafelyCancel ? null : job.pid ?? null,
pidIdentity: canSafelyCancel ? null : job.pidIdentity ?? null,
phase: canSafelyCancel ? "cancelled" : "cancel_failed",
}
);
} catch {
// Ignore state transition races during session shutdown.
}
}
}

// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------

function handleSessionStart(input) {
const cwd = input.cwd || process.cwd();
const nestedSession = isNestedCodexSession(input.session_id);
Expand All @@ -137,24 +65,6 @@ function handleSessionStart(input) {
}
}

function handleSessionEnd(input) {
const cwd = input.cwd || process.cwd();
let sessionId = input.session_id || process.env[SESSION_ID_ENV] || null;
if (!sessionId) {
try {
sessionId = getCurrentSession(resolveWorkspaceRoot(cwd));
} catch {
sessionId = null;
}
}

// Clean up tracked jobs for this session
cleanupSessionJobs(cwd, sessionId);
if (sessionId) {
clearCurrentSession(cwd, sessionId);
}
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
Expand All @@ -169,11 +79,6 @@ async function main() {
if (eventName === "SessionStart" || !eventName) {
// Default to SessionStart (Codex invokes this on session start)
handleSessionStart(input);
return;
}

if (eventName === "SessionEnd") {
handleSessionEnd(input);
}
}

Expand Down
21 changes: 18 additions & 3 deletions hooks/stop-review-gate-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ 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 MAX_INLINE_REASON_CHARS = 1_500;

function emitDecision(payload) {
process.stdout.write(`${JSON.stringify(payload)}\n`);
Expand All @@ -65,6 +66,19 @@ function logNote(message) {
process.stderr.write(`${message}\n`);
}

function boundReasonForHookOutput(reason, runId) {
const text = String(reason ?? "");
if (text.length <= MAX_INLINE_REASON_CHARS) {
return text;
}
const suffix = [
"",
"",
`Full stop-review output was saved in the ${runId} snapshot.`
].join("\n");
return `${text.slice(0, MAX_INLINE_REASON_CHARS).trimEnd()}…${suffix}`;
}

function buildSetupNote(cwd) {
const availability = getClaudeAvailability(cwd);
if (!availability.available) {
Expand Down Expand Up @@ -426,11 +440,12 @@ async function main() {
runningTaskNote,
...fingerprintFields,
});
const inlineReason = runningTaskNote
? `${runningTaskNote} ${review.reason}`
: review.reason;
emitDecision({
decision: "block",
reason: runningTaskNote
? `${runningTaskNote} ${review.reason}`
: review.reason,
reason: boundReasonForHookOutput(inlineReason, stopReviewRun.runId),
});
return;
}
Expand Down
37 changes: 30 additions & 7 deletions hooks/unread-result-hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import { fileURLToPath } from "node:url";

import { readHookInput } from "./lib/hook-input.mjs";
import { cleanupAfterOfficialUninstall } from "./lib/plugin-install-guard.mjs";
import { getConfig, listJobs, patchJob, writeTurnBaseline } from "../scripts/lib/state.mjs";
import {
getConfig,
listJobs,
patchJob,
setCurrentSession,
writeTurnBaseline,
} from "../scripts/lib/state.mjs";
import { getWorkingTreeFingerprint } from "../scripts/lib/git.mjs";
import { nowIso, SESSION_ID_ENV } from "../scripts/lib/tracked-jobs.mjs";
import { resolveWorkspaceRoot } from "../scripts/lib/workspace.mjs";
Expand Down Expand Up @@ -56,12 +62,12 @@ function buildAdditionalContext(jobs) {
].join("\n");
}

function selectUnreadCompletedJobs(workspaceRoot, sessionId) {
function selectUnreadCompletedJobs(jobs, sessionId) {
if (!sessionId) {
return [];
}

return listJobs(workspaceRoot)
return jobs
.filter((job) => job.sessionId === sessionId)
.filter((job) => job.status === "completed")
.filter((job) => !job.resultViewedAt)
Expand All @@ -82,6 +88,17 @@ function markJobsNotified(workspaceRoot, jobs) {
}
}

function listJobsSafely(workspaceRoot) {
try {
// listJobs() triggers the PID-reuse-safe stale job reaper.
return listJobs(workspaceRoot);
} catch {
// Best effort only: unread-result steering should not fail a user prompt
// because stale job reaping hit a filesystem or process-inspection race.
return [];
}
}

function captureTurnBaseline(workspaceRoot, sessionId, cwd) {
if (!sessionId) {
return;
Expand Down Expand Up @@ -116,22 +133,28 @@ async function main() {
return;
}

try {
setCurrentSession(workspaceRoot, sessionId);
} catch {
// Best effort only: an invalid session id should not fail a user prompt.
}
const config = getConfig(workspaceRoot);
if (config.stopReviewGate) {
captureTurnBaseline(workspaceRoot, sessionId, cwd);
}
const jobs = listJobsSafely(workspaceRoot);

if (isExplicitClaudeStatusRequest(prompt)) {
return;
}

const jobs = selectUnreadCompletedJobs(workspaceRoot, sessionId);
if (jobs.length === 0) {
const unreadJobs = selectUnreadCompletedJobs(jobs, sessionId);
if (unreadJobs.length === 0) {
return;
}

markJobsNotified(workspaceRoot, jobs);
process.stdout.write(`${buildAdditionalContext(jobs)}\n`);
markJobsNotified(workspaceRoot, unreadJobs);
process.stdout.write(`${buildAdditionalContext(unreadJobs)}\n`);
}

main().catch((error) => {
Expand Down
1 change: 1 addition & 0 deletions internal-skills/cli-runtime/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Routing controls:
- `--view-state on-success` means the user will see this companion result in the current turn, so the companion may mark it viewed on success.
- `--view-state defer` means the parent is not waiting, so the companion must leave the result unread until the user explicitly checks it.
- `--owner-session-id <session-id>` is an internal parent-session routing control. Preserve it when present so tracked jobs remain visible to the parent session's `$cc:status` / `$cc:result`.
- Whenever the command includes a reserved `--job-id`, also include `--cwd <workspace-root>` using `workspaceRoot` from the same routing-context response. Reserved job ids are workspace-scoped.
- Never emit an empty routing placeholder such as `--owner-session-id --job-id`.
- Do not add `--quiet-progress` by default for built-in rescue forwarding. Let companion stderr progress remain available in the spawned agent thread.
- If the free-text task begins with `/`, treat that slash command as literal Claude Code task text to forward unchanged. Do not execute it as a local Codex slash command or answer it inline.
Expand Down
1 change: 1 addition & 0 deletions internal-skills/review-runtime/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Foreground contract:
Background contract:
- Use `background-routing-context --kind review --json` before spawning the forwarding child.
- Preserve `--job-id` only when reserved by the parent helper.
- Whenever preserving that reserved `--job-id`, also pass `--cwd <workspace-root>` using `workspaceRoot` from the same helper response. Reserved job ids are workspace-scoped.
- Preserve `--owner-session-id` only when the parent helper returned a non-empty owner session id.
- Preserve the parent notification path only when the helper returned a non-empty parent thread id.
- Never emit an empty routing placeholder such as `--owner-session-id --job-id`.
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.2.1",
"version": "1.3.0",
"description": "Claude Code Plugin for Codex by Sendbird",
"type": "module",
"author": {
Expand Down
Loading