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.0.6",
"version": "1.0.7",
"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
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,30 @@ on:
pull_request:

jobs:
ci:
core-cross-platform:
name: Core checks (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- windows-latest
- macos-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run check:version-sync
- run: npm run check:changelog
- run: npm run lint
- run: npm run typecheck
- run: npm run test:cross-platform

linux-full:
name: Full CI (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## v1.0.7

- Add GitHub CI coverage across Windows, macOS, and Linux, with a portable cross-platform test suite plus Linux-only full integration/E2E coverage.
- Harden background routing by validating `parentThreadId`, combining reserved-job and session-routing metadata into one helper, and making background review/rescue explicitly use built-in forwarding subagents rather than direct detached companion processes.
- Stop exposing managed job log paths through user/model-facing status and result surfaces while keeping on-disk logs for debugging.
- Make installed skill-path materialization consistent for both staged installs and direct local-checkout installs, and centralize installer path helpers for reuse.
- Switch sandbox temp-dir settings from a hardcoded `/tmp` path to the OS temp directory so the runtime configuration stays valid off Linux.

## v1.0.6

- Restore parent-session ownership for built-in background rescue/review runs so resume candidates, plain `$cc:status`, and no-argument `$cc:result` stay aligned after nested child sessions run.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ That includes:
- Session-scoped tracked jobs with status, result, and cancel commands
- Background completion nudges that steer you to the right `$cc:result <job-id>`
- An optional stop-time review gate
- GitHub CI coverage on Windows, macOS, and Linux

It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) but runs in the opposite direction.

Expand All @@ -53,6 +54,9 @@ That's the entire install. It:
- Enables `codex_hooks = true`
- Installs lifecycle, review-gate, and unread-result hooks

On Windows, prefer the `npx` path above. The shell-script installer below is POSIX-only.
The maintained CI matrix now covers Windows, macOS, and Linux. The `npx` install path is the cross-platform path we test on every release.

> **Prerequisites:** Node.js 18+, Codex with hook support, and `claude` CLI installed and authenticated.
> If you don't have the Claude CLI yet:
> ```bash
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.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cc-plugin-codex",
"version": "1.0.6",
"version": "1.0.7",
"description": "Claude Code Plugin for Codex by Sendbird",
"type": "module",
"author": {
Expand Down Expand Up @@ -51,6 +51,7 @@
"prepack": "npm run check:version-sync && npm run check:changelog",
"setup:git-hooks": "node scripts/setup-git-hooks.mjs",
"test": "node --test tests/*.test.mjs",
"test:cross-platform": "node --test tests/args.test.mjs tests/changelog.test.mjs tests/claude-cli.test.mjs tests/fs.test.mjs tests/prompts.test.mjs tests/render.test.mjs tests/sandbox-modes.test.mjs tests/skills-contracts.test.mjs tests/structured-output.test.mjs tests/version-sync.test.mjs",
"test:integration": "node --test tests/integration/*.test.mjs",
"test:e2e": "node --test tests/e2e/*.test.mjs",
"uninstall:codex": "node scripts/installer-cli.mjs uninstall",
Expand Down
107 changes: 98 additions & 9 deletions scripts/claude-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ function printUsage() {
" node scripts/claude-companion.mjs status [job-id] [--all] [--json]",
" node scripts/claude-companion.mjs result [job-id] [--json]",
" node scripts/claude-companion.mjs cancel [job-id] [--json]",
" node scripts/claude-companion.mjs session-routing-context [--cwd <path>] [--json]",
" node scripts/claude-companion.mjs background-routing-context --kind <review|task> [--cwd <path>] [--json]",
" node scripts/claude-companion.mjs task-resume-candidate [--json]",
" node scripts/claude-companion.mjs task-reserve-job [--json]",
" node scripts/claude-companion.mjs review-reserve-job [--json]"
Expand All @@ -131,7 +133,7 @@ function printUsage() {

function outputResult(value, asJson) {
if (asJson) {
console.log(JSON.stringify(value, null, 2));
console.log(JSON.stringify(value, redactOutputReplacer, 2));
} else {
process.stdout.write(typeof value === "string" ? value : JSON.stringify(value, null, 2));
}
Expand All @@ -141,6 +143,13 @@ function outputCommandResult(payload, rendered, asJson) {
outputResult(asJson ? payload : rendered, asJson);
}

function redactOutputReplacer(key, value) {
if (key === "logFile") {
return undefined;
}
return value;
}

// ---------------------------------------------------------------------------
// Normalization
// ---------------------------------------------------------------------------
Expand All @@ -165,24 +174,58 @@ function resolveExplicitJobId(value, workspaceRoot) {
if (value == null || String(value).trim() === "") {
return null;
}
const explicitJobId = sanitizeId(String(value).trim(), "job ID");
if (readStoredJob(workspaceRoot, explicitJobId)) {
throw new Error(`Claude Code job id ${explicitJobId} already exists.`);
const explicitJobId = String(value).trim();
if (explicitJobId.startsWith("--")) {
throw new Error(`Invalid job ID: ${explicitJobId}`);
}
if (!fs.existsSync(resolveReservedJobFile(workspaceRoot, explicitJobId))) {
const safeJobId = sanitizeId(explicitJobId, "job ID");
if (readStoredJob(workspaceRoot, safeJobId)) {
throw new Error(`Claude Code job id ${safeJobId} already exists.`);
}
if (!fs.existsSync(resolveReservedJobFile(workspaceRoot, safeJobId))) {
throw new Error(
`Claude Code job id ${explicitJobId} is not reserved. Reserve one with the companion reserve-job helper before reusing it.`
`Claude Code job id ${safeJobId} is not reserved. Reserve one with the companion reserve-job helper before reusing it.`
);
}
return explicitJobId;
return safeJobId;
}

function resolveOwnerSessionId(value) {
const trimmed = value == null ? "" : String(value).trim();
if (!trimmed) return null;
if (trimmed.startsWith("--")) {
throw new Error(`Invalid session ID: ${trimmed}`);
}
return sanitizeId(trimmed, "session ID");
}

function resolveParentThreadId() {
const threadId = String(process.env.CODEX_THREAD_ID ?? "").trim();
if (!threadId) {
return null;
}
if (threadId.startsWith("--")) {
return null;
}
try {
return sanitizeId(threadId, "parent thread ID");
} catch {
return null;
}
}

function buildSessionRoutingContext(cwd) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
return {
workspaceRoot,
ownerSessionId:
resolveOwnerSessionId(
process.env[SESSION_ID_ENV] ?? getCurrentSession(workspaceRoot) ?? null
),
parentThreadId: resolveParentThreadId(),
};
}

function alignCurrentSessionToOwner(workspaceRoot, ownerSessionId) {
if (!ownerSessionId) {
return;
Expand Down Expand Up @@ -1492,8 +1535,9 @@ function handleTaskResumeCandidate(argv) {
});

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const sessionId = process.env[SESSION_ID_ENV] ?? getCurrentSession(workspaceRoot) ?? null;
const routing = buildSessionRoutingContext(cwd);
const workspaceRoot = routing.workspaceRoot;
const sessionId = routing.ownerSessionId;
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const candidate =
sessionId == null
Expand Down Expand Up @@ -1531,6 +1575,45 @@ function handleTaskResumeCandidate(argv) {
outputCommandResult(payload, rendered, options.json);
}

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

const cwd = resolveCommandCwd(options);
const payload = buildSessionRoutingContext(cwd);
const rendered =
`Owner session: ${payload.ownerSessionId ?? "(none)"}\n` +
`Parent thread: ${payload.parentThreadId ?? "(none)"}\n`;
outputCommandResult(payload, rendered, options.json);
}

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

const kind = String(options.kind ?? "").trim().toLowerCase();
const prefix = kind === "review" ? "review" : kind === "task" ? "task" : null;
if (!prefix) {
throw new Error("background-routing-context requires --kind review or --kind task.");
}

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace({ cwd });
const payload = {
...buildSessionRoutingContext(cwd),
jobId: reserveUniqueJobId(workspaceRoot, prefix, prefix),
};
const rendered =
`Job: ${payload.jobId}\n` +
`Owner session: ${payload.ownerSessionId ?? "(none)"}\n` +
`Parent thread: ${payload.parentThreadId ?? "(none)"}\n`;
outputCommandResult(payload, rendered, options.json);
}

function handleReserveJob(argv, prefix) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd"],
Expand Down Expand Up @@ -1670,6 +1753,12 @@ async function main() {
case "result":
handleResult(argv);
break;
case "session-routing-context":
handleSessionRoutingContext(argv);
break;
case "background-routing-context":
handleBackgroundRoutingContext(argv);
break;
case "task-resume-candidate":
handleTaskResumeCandidate(argv);
break;
Expand Down
9 changes: 4 additions & 5 deletions scripts/installer-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { resolveCodexHome } from "./lib/codex-paths.mjs";
import { samePath, resolveCodexHome } from "./lib/codex-paths.mjs";
import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs";

const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, "..");
Expand Down Expand Up @@ -55,10 +56,6 @@ function parseArgs(argv) {
return { command };
}

function samePath(a, b) {
return path.resolve(a) === path.resolve(b);
}

function ensureEmptyDir(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
fs.mkdirSync(dirPath, { recursive: true });
Expand Down Expand Up @@ -97,6 +94,7 @@ function stageInstall(sourceRoot, installDir) {

try {
copyDistribution(sourceRoot, stagingDir);
materializeInstalledSkillPaths(stagingDir, installDir);
fs.rmSync(installDir, { recursive: true, force: true });
fs.renameSync(stagingDir, installDir);
} catch (error) {
Expand All @@ -113,6 +111,7 @@ function runLocalInstaller(installDir, command) {
...process.env,
HOME: process.env.HOME || os.homedir(),
USERPROFILE: process.env.USERPROFILE || process.env.HOME || os.homedir(),
CC_PLUGIN_CODEX_SKILLS_MATERIALIZED: "1",
},
});

Expand Down
37 changes: 35 additions & 2 deletions scripts/lib/args.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,16 @@ export function parseArgs(argv, config = {}) {

if (valueOptions.has(key)) {
const nextValue = inlineValue ?? argv[index + 1];
if (nextValue === undefined) {
if (
nextValue === undefined ||
(inlineValue === undefined &&
isRecognizedOptionToken(
nextValue,
valueOptions,
booleanOptions,
aliasMap
))
) {
throw new Error(`Missing value for --${rawKey}`);
}
options[key] = nextValue;
Expand All @@ -63,7 +72,15 @@ export function parseArgs(argv, config = {}) {

if (valueOptions.has(key)) {
const nextValue = argv[index + 1];
if (nextValue === undefined) {
if (
nextValue === undefined ||
isRecognizedOptionToken(
nextValue,
valueOptions,
booleanOptions,
aliasMap
)
) {
throw new Error(`Missing value for -${shortKey}`);
}
options[key] = nextValue;
Expand All @@ -77,6 +94,22 @@ export function parseArgs(argv, config = {}) {
return { options, positionals };
}

function isRecognizedOptionToken(token, valueOptions, booleanOptions, aliasMap) {
if (!token || token === "-" || token === "--" || !token.startsWith("-")) {
return token === "--";
}

if (token.startsWith("--")) {
const [rawKey] = token.slice(2).split("=", 2);
const key = aliasMap[rawKey] ?? rawKey;
return valueOptions.has(key) || booleanOptions.has(key);
}

const shortKey = token.slice(1);
const key = aliasMap[shortKey] ?? shortKey;
return valueOptions.has(key) || booleanOptions.has(key);
}

export function splitRawArgumentString(raw) {
const tokens = [];
let current = "";
Expand Down
Loading