Skip to content
Open
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
115 changes: 110 additions & 5 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import process from "node:process";
import { fileURLToPath } from "node:url";

import { parseArgs, splitRawArgumentString } from "./lib/args.mjs";
import { tierDefaultsForVerb } from "./lib/rig-edition.mjs";
import { loadFanoutBriefsFromFile, runFanout } from "./lib/fanout.mjs";
import { runCouncil } from "./lib/council.mjs";
import { CLOUD_SUBCOMMANDS, runCloudCommand } from "./lib/cloud.mjs";
import {
buildPersistentTaskThreadName,
DEFAULT_CONTINUE_PROMPT,
Expand All @@ -23,7 +27,7 @@ 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 { collectReviewContext, ensureGitRepository, getRepoRoot, resolveReviewTarget } from "./lib/git.mjs";
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs";
import {
Expand Down Expand Up @@ -58,6 +62,9 @@ import {
renderReviewResult,
renderStoredJobResult,
renderCancelReport,
renderCloudResult,
renderCouncilResult,
renderFanoutResult,
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
Expand All @@ -79,7 +86,10 @@ 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] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [--tier <sol|terra|luna>] [prompt]",
" node scripts/codex-companion.mjs fanout --briefs <path.json> --root <worktree-root> [--concurrency <n>] [--cleanup] [--json]",
" node scripts/codex-companion.mjs council [--seats <n>] [--tier <sol|terra|luna>] [--effort <effort>] [--topic-file <path>] [topic]",
" node scripts/codex-companion.mjs cloud <exec|status|list|apply|diff> [task-id] [--env <id>] [--branch <ref>] [--attempts <n>] [--attempt <n>] [--limit <n>] [--cursor <cursor>] [--json]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
Expand Down Expand Up @@ -761,7 +771,7 @@ async function handleReview(argv) {

async function handleTask(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["model", "effort", "cwd", "prompt-file"],
valueOptions: ["model", "effort", "cwd", "prompt-file", "tier"],
booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"],
aliasMap: {
m: "model"
Expand All @@ -770,8 +780,12 @@ async function handleTask(argv) {

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const model = normalizeRequestedModel(options.model);
const effort = normalizeReasoningEffort(options.effort);
// --tier resolves model + effort via the rig-edition tier table, bypassing
// the legacy --model/--effort normalizers below. Omit --tier to keep the
// prior behavior unchanged.
const tierDefaults = options.tier ? tierDefaultsForVerb("delegate", { tier: options.tier, effort: options.effort }) : null;
const model = tierDefaults ? tierDefaults.modelId : normalizeRequestedModel(options.model);
const effort = tierDefaults ? tierDefaults.effort : normalizeReasoningEffort(options.effort);
const prompt = readTaskPrompt(cwd, options, positionals);

const resumeLast = Boolean(options["resume-last"] || options.resume);
Expand Down Expand Up @@ -822,6 +836,88 @@ async function handleTask(argv) {
);
}

async function handleFanout(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd", "briefs", "root", "concurrency"],
booleanOptions: ["json", "cleanup"]
});

if (!options.briefs) {
throw new Error("Provide --briefs <path-to-json-file>.");
}
if (!options.root) {
throw new Error("Provide --root <worktree-root-directory>.");
}

const cwd = resolveCommandCwd(options);
ensureGitRepository(cwd);
const repoRoot = getRepoRoot(cwd);
const briefs = loadFanoutBriefsFromFile(path.resolve(cwd, options.briefs));
const worktreeRoot = path.resolve(cwd, options.root);

const aggregate = await runFanout(repoRoot, worktreeRoot, briefs, {
concurrency: options.concurrency,
cleanup: Boolean(options.cleanup)
});

outputResult(options.json ? aggregate : renderFanoutResult(aggregate), options.json);
}

async function handleCouncil(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["cwd", "seats", "tier", "effort", "topic-file"],
booleanOptions: ["json"]
});

const cwd = resolveCommandCwd(options);
const topic = options["topic-file"]
? fs.readFileSync(path.resolve(cwd, options["topic-file"]), "utf8")
: positionals.join(" ") || readStdinIfPiped();

if (!topic || !topic.trim()) {
throw new Error("Provide a topic, a --topic-file, or piped stdin.");
}

const result = await runCouncil(cwd, topic, {
seats: options.seats ? Number(options.seats) : undefined,
tier: options.tier,
effort: options.effort
});

outputResult(options.json ? result : renderCouncilResult(result), options.json);
}

async function handleCloud(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["cwd", "env", "branch", "attempts", "attempt", "limit", "cursor"],
booleanOptions: ["json"]
});

const [subcommand, ...rest] = positionals;
if (!subcommand || !CLOUD_SUBCOMMANDS.includes(subcommand)) {
throw new Error(`Provide a "codex cloud" subcommand. Use one of: ${CLOUD_SUBCOMMANDS.join(", ")}.`);
}

const cwd = resolveCommandCwd(options);
const taskId = ["status", "apply", "diff"].includes(subcommand) ? rest[0] : undefined;
const query = subcommand === "exec" ? rest.join(" ") : undefined;

const result = runCloudCommand(subcommand, {
cwd,
env: options.env,
branch: options.branch,
attempts: options.attempts,
attempt: options.attempt,
limit: options.limit,
cursor: options.cursor,
json: options.json,
taskId,
query
});

outputResult(options.json ? result : renderCloudResult(result), options.json);
}

async function handleTransfer(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd", "source"],
Expand Down Expand Up @@ -1043,6 +1139,15 @@ async function main() {
case "task":
await handleTask(argv);
break;
case "fanout":
await handleFanout(argv);
break;
case "council":
await handleCouncil(argv);
break;
case "cloud":
await handleCloud(argv);
break;
case "transfer":
await handleTransfer(argv);
break;
Expand Down
15 changes: 13 additions & 2 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
import readline from "node:readline";
import { parseBrokerEndpoint } from "./broker-endpoint.mjs";
import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs";
import { terminateProcessTree } from "./process.mjs";
import { resolveWindowsShell, terminateProcessTree } from "./process.mjs";

const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url);
const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8"));
Expand Down Expand Up @@ -191,7 +191,18 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
cwd: this.cwd,
env: this.options.env ?? process.env,
stdio: ["pipe", "pipe", "pipe"],
shell: process.platform === "win32" ? (process.env.SHELL || true) : false,
// Always use the deterministic Windows shell (System32\cmd.exe, resolved via
// resolveWindowsShell) to wrap this spawn, never process.env.SHELL or
// process.env.ComSpec (plain `shell: true` falls back to the latter). This wrapper
// only resolves the codex executable (npm installs ship a .cmd shim that
// CreateProcess cannot exec directly); it has no bearing on what shell Codex uses
// internally for its own command-execution tool, since SHELL is passed through via
// `env` above regardless of this option. Trusting an arbitrary user-configured
// SHELL or ComSpec here (git-bash, WSL's bash.exe shim, an unresolvable literal
// path, a non-cmd.exe ComSpec, etc.) to wrap a JSON-RPC stdio pipe is unpredictable
// and can hang the handshake indefinitely with no surfaced error (#236). See also
// #138/#178.
shell: process.platform === "win32" ? resolveWindowsShell() : false,
windowsHide: true
});

Expand Down
133 changes: 133 additions & 0 deletions plugins/codex/scripts/lib/cloud.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Rig-edition cloud verb: a thin, envelope-wrapping passthrough over the
* `codex cloud` CLI surface (EXPERIMENTAL upstream; verified 0.144.1).
*
* `codex cloud` browses and drives OpenAI-hosted Codex Cloud tasks -- a
* different execution target than the local app-server turns the rest of
* rig-edition drives. It is not an app-server JSON-RPC session, so there is
* no structured Codex-side envelope to parse; this module builds one from
* the CLI's exit status instead.
*
* Verified subcommand surface (codex cloud --help / <subcommand> --help,
* 0.144.1): exec, status, list, apply, diff. `exec` requires --env <ENV_ID>
* (a Codex Cloud environment id, not a local worktree) and takes an
* optional --branch/--attempts; `list` supports --env/--limit/--cursor/
* --json; `status`/`apply`/`diff` take a positional task id, with
* apply/diff also accepting --attempt.
*/
import { runCommand } from "./process.mjs";

export const CLOUD_SUBCOMMANDS = Object.freeze(["exec", "status", "list", "apply", "diff"]);

function pushValueArg(args, flag, value) {
if (value === undefined || value === null || value === "") {
return;
}
args.push(flag, String(value));
}

function requireTaskId(options) {
if (!isNonEmptyString(options.taskId)) {
throw new Error("This `codex cloud` subcommand requires a task id.");
}
return String(options.taskId);
}

function isNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0;
}

function buildExecArgs(options) {
if (!isNonEmptyString(options.env)) {
throw new Error("`codex cloud exec` requires --env <ENV_ID>.");
}
const args = ["--env", String(options.env)];
pushValueArg(args, "--attempts", options.attempts);
pushValueArg(args, "--branch", options.branch);
if (isNonEmptyString(options.query)) {
args.push(String(options.query));
}
return args;
}

function buildStatusArgs(options) {
return [requireTaskId(options)];
}

function buildListArgs(options) {
const args = [];
pushValueArg(args, "--env", options.env);
pushValueArg(args, "--limit", options.limit);
pushValueArg(args, "--cursor", options.cursor);
if (options.json) {
args.push("--json");
}
return args;
}

function buildApplyArgs(options) {
const args = [requireTaskId(options)];
pushValueArg(args, "--attempt", options.attempt);
return args;
}

function buildDiffArgs(options) {
const args = [requireTaskId(options)];
pushValueArg(args, "--attempt", options.attempt);
return args;
}

const ARG_BUILDERS = Object.freeze({
exec: buildExecArgs,
status: buildStatusArgs,
list: buildListArgs,
apply: buildApplyArgs,
diff: buildDiffArgs
});

/**
* Builds the full `codex cloud <subcommand> ...` argv for a given verb and
* options, applying the same validation the upstream CLI would enforce.
* @param {string} subcommand
* @param {Record<string, unknown>} [options]
* @returns {string[]}
*/
export function buildCloudArgs(subcommand, options = {}) {
const builder = ARG_BUILDERS[subcommand];
if (!builder) {
throw new Error(`Unknown "codex cloud" subcommand "${subcommand}". Use one of: ${CLOUD_SUBCOMMANDS.join(", ")}.`);
}
return ["cloud", subcommand, ...builder(options)];
}

function buildEnvelope(subcommand, result) {
const stdout = (result.stdout ?? "").trim();
const stderr = (result.stderr ?? "").trim();
const succeeded = !result.error && result.status === 0;

return Object.freeze({
status: succeeded ? "DONE" : "BLOCKED",
summary: succeeded ? `codex cloud ${subcommand} completed.` : `codex cloud ${subcommand} failed.`,
files_modified: Object.freeze([]),
concerns: Object.freeze(succeeded ? [] : [stderr || stdout || `exit ${result.status}`]),
blocked_reason: succeeded ? null : "CLOUD_COMMAND_FAILED"
});
}

/**
* Runs a `codex cloud` subcommand and wraps the result in a typed envelope.
* @param {string} subcommand
* @param {Record<string, unknown> & { cwd?: string, runCommand?: typeof runCommand }} [options]
*/
export function runCloudCommand(subcommand, options = {}) {
const runner = options.runCommand ?? runCommand;
const args = buildCloudArgs(subcommand, options);
const result = runner("codex", args, { cwd: options.cwd, shell: false });

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 Use the Windows shell when invoking codex cloud

This forces runCommand to bypass its Windows default shell. For npm-installed Codex on Windows the executable is the codex.cmd shim (the adjacent app-server spawn was changed for this), so codex cloud ... fails before the CLI starts whenever only that shim is on PATH; let runCommand choose its default shell or use the resolved Windows shell on win32.

Useful? React with 👍 / 👎.


return Object.freeze({
exitStatus: result.status,
stdout: result.stdout,
stderr: result.stderr,
envelope: buildEnvelope(subcommand, result)
});
}
Loading