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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ they already have.

- `/codex:review` for a normal read-only Codex review
- `/codex:adversarial-review` for a steerable challenge review
- `/codex:cli` for direct Codex CLI task execution without the rescue subagent
- `/codex:goal` to set a persistent goal Codex can continue over time
- `/codex:rescue`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work and manage background jobs

## Requirements
Expand Down Expand Up @@ -162,6 +164,45 @@ Ask Codex to redesign the database connection to be more resilient.
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
- follow-up rescue requests can continue the latest Codex task in the repo

### `/codex:cli`

Runs a direct Codex CLI task through the companion runtime, without routing through the rescue subagent.

Use it when you want exact control over a Codex task from Claude Code.

Examples:

```bash
/codex:cli investigate why the build fails
/codex:cli --write fix the failing parser test
/codex:cli --resume continue the current goal
/codex:cli --background --write implement the migration
```

It supports `--background`, `--write`, `--resume`, `--fresh`, `--model`, and `--effort`. By default it is read-only; pass `--write` when Codex should edit files.

### `/codex:goal`

Sets or manages a persistent goal Codex can continue over time.

Use it when a task is larger than one turn and you want Codex to keep a durable objective attached to the Codex thread.

Examples:

```bash
/codex:goal --fresh --budget 80000 finish the auth migration with tests passing
/codex:goal --show
/codex:goal --status paused
/codex:goal --status complete
/codex:goal --clear
```

After setting a goal, continue it from Claude Code with:

```bash
/codex:cli --resume
```

### `/codex:status`

Shows running and recent Codex jobs for the current repository.
Expand Down
16 changes: 16 additions & 0 deletions plugins/codex/commands/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
description: Run a direct Codex CLI task through the shared Codex runtime
argument-hint: "[--background] [--write] [--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [what Codex should do]"
disable-model-invocation: true
allowed-tools: Bash(node:*)
---

!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cli "$ARGUMENTS"`

Present the full command output to the user. Do not summarize or condense it.

This is a direct Codex CLI task entrypoint:
- use `--write` when Codex may edit files
- use `--resume` to continue the latest Codex task or goal thread
- use `--fresh` to start a new thread
- use `--background` for long-running work, then check `/codex:status`
18 changes: 18 additions & 0 deletions plugins/codex/commands/goal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
description: Manage a persistent Codex goal for this repository
argument-hint: "[--show|--clear] [--fresh|--resume|--thread-id <id>] [--budget <tokens>] [--status <active|paused|budgetLimited|complete>] [goal objective]"
disable-model-invocation: true
allowed-tools: Bash(node:*)
---

!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" goal "$ARGUMENTS"`

Present the full command output to the user. Do not summarize or condense it.

This command manages the persistent goal Codex can continue over time:
- pass an objective to create or update the goal
- use `--budget <tokens>` to set a token budget
- use `--status <active|paused|budgetLimited|complete>` to update progress state
- use `--show` to inspect the current goal
- use `--clear` to remove the current goal
- continue the goal work with `/codex:cli --resume`
7 changes: 5 additions & 2 deletions plugins/codex/scripts/app-server-broker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import path from "node:path";
import process from "node:process";

import { parseArgs } from "./lib/args.mjs";
import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs";
import { BROKER_BUSY_RPC_CODE, CodexAppServerClient, EXPERIMENTAL_CAPABILITIES } from "./lib/app-server.mjs";
import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs";

const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);
Expand Down Expand Up @@ -65,7 +65,10 @@ async function main() {
const pidFile = options["pid-file"] ? path.resolve(options["pid-file"]) : null;
writePidFile(pidFile);

const appClient = await CodexAppServerClient.connect(cwd, { disableBroker: true });
const appClient = await CodexAppServerClient.connect(cwd, {
disableBroker: true,
capabilities: EXPERIMENTAL_CAPABILITIES
});
let activeRequestSocket = null;
let activeStreamSocket = null;
let activeStreamThreadIds = null;
Expand Down
186 changes: 185 additions & 1 deletion plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ import { fileURLToPath } from "node:url";
import { parseArgs, splitRawArgumentString } from "./lib/args.mjs";
import {
buildPersistentTaskThreadName,
clearThreadGoal,
DEFAULT_CONTINUE_PROMPT,
findLatestTaskThread,
getCodexAuthStatus,
getCodexAvailability,
getSessionRuntimeStatus,
interruptAppServerTurn,
parseStructuredOutput,
readThreadGoal,
readOutputSchema,
runAppServerReview,
runAppServerTurn
runAppServerTurn,
setThreadGoal,
startPersistentGoalThread
} from "./lib/codex.mjs";
import { readStdinIfPiped } from "./lib/fs.mjs";
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
Expand Down Expand Up @@ -56,6 +60,7 @@ import {
renderReviewResult,
renderStoredJobResult,
renderCancelReport,
renderGoalReport,
renderJobStatusReport,
renderSetupReport,
renderStatusReport,
Expand All @@ -67,8 +72,10 @@ const REVIEW_SCHEMA = path.join(ROOT_DIR, "schemas", "review-output.schema.json"
const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000;
const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000;
const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
const VALID_GOAL_STATUSES = new Set(["active", "paused", "budgetLimited", "complete"]);
const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]);
const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn.";
const GOAL_THREAD_CONFIG_KEY = "goalThreadId";

function printUsage() {
console.log(
Expand All @@ -78,6 +85,8 @@ function printUsage() {
" 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 cli [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs goal [--show|--clear] [--fresh|--resume|--thread-id <id>] [--budget <tokens>] [--status <active|paused|budgetLimited|complete>] [objective]",
" node scripts/codex-companion.mjs status [job-id] [--all] [--json]",
" node scripts/codex-companion.mjs result [job-id] [--json]",
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
Expand Down Expand Up @@ -124,6 +133,39 @@ function normalizeReasoningEffort(effort) {
return normalized;
}

function normalizeGoalStatus(status) {
if (status == null) {
return null;
}
const normalized = String(status).trim();
if (!normalized) {
return null;
}
const canonical = normalized === "budget-limited" ? "budgetLimited" : normalized;
if (!VALID_GOAL_STATUSES.has(canonical)) {
throw new Error("Unsupported goal status. Use one of: active, paused, budgetLimited, complete.");
}
return canonical;
}

function normalizeGoalBudget(value) {
if (value == null) {
return undefined;
}
const raw = String(value).trim().toLowerCase();
if (!raw) {
return undefined;
}
if (raw === "none" || raw === "unlimited" || raw === "null") {
return null;
}
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error("Goal budget must be a positive integer token count, or `none`.");
}
return parsed;
}

function normalizeArgv(argv) {
if (argv.length === 1) {
const [raw] = argv;
Expand Down Expand Up @@ -340,6 +382,11 @@ async function resolveLatestTrackedTaskThread(cwd, options = {}) {
throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`);
}

const config = getConfig(workspaceRoot);
if (typeof config[GOAL_THREAD_CONFIG_KEY] === "string" && config[GOAL_THREAD_CONFIG_KEY].trim()) {
return { id: config[GOAL_THREAD_CONFIG_KEY].trim() };
}

const trackedTask = findLatestResumableTaskJob(visibleJobs);
if (trackedTask) {
return { id: trackedTask.threadId };
Expand Down Expand Up @@ -619,6 +666,62 @@ function readTaskPrompt(cwd, options, positionals) {
return positionalPrompt || readStdinIfPiped();
}

function readGoalObjective(cwd, options, positionals) {
if (options["objective-file"]) {
return fs.readFileSync(path.resolve(cwd, options["objective-file"]), "utf8").trim();
}
return positionals.join(" ").trim();
}

function getStoredGoalThreadId(workspaceRoot) {
const config = getConfig(workspaceRoot);
const threadId = config[GOAL_THREAD_CONFIG_KEY];
return typeof threadId === "string" && threadId.trim() ? threadId.trim() : null;
}

function storeGoalThreadId(workspaceRoot, threadId) {
setConfig(workspaceRoot, GOAL_THREAD_CONFIG_KEY, threadId);
}

async function resolveGoalThread(cwd, workspaceRoot, options, objective) {
if (options["thread-id"]) {
return { id: String(options["thread-id"]).trim(), created: false };
}

if (options.fresh) {
const thread = await startPersistentGoalThread(cwd, { objective });
storeGoalThreadId(workspaceRoot, thread.id);
return { id: thread.id, created: true };
}

const storedThreadId = getStoredGoalThreadId(workspaceRoot);
if (storedThreadId) {
return { id: storedThreadId, created: false };
}

if (!objective) {
return { id: null, created: false };
}

const thread = await startPersistentGoalThread(cwd, { objective });
storeGoalThreadId(workspaceRoot, thread.id);
return { id: thread.id, created: true };
}

function buildGoalSetParams({ objective, status, tokenBudget }) {
const params = {};
if (objective) {
params.objective = objective;
}
if (status) {
params.status = status;
}
if (tokenBudget !== undefined) {
params.tokenBudget = tokenBudget;
}
return params;
}

function requireTaskRequest(prompt, resumeLast) {
if (!prompt && !resumeLast) {
throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last.");
Expand Down Expand Up @@ -792,6 +895,81 @@ async function handleTask(argv) {
);
}

async function handleGoal(argv) {
const { options, positionals } = parseCommandInput(argv, {
valueOptions: ["cwd", "thread-id", "budget", "status", "objective-file"],
booleanOptions: ["json", "show", "clear", "fresh", "resume"]
});

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const objective = readGoalObjective(cwd, options, positionals);
const status = normalizeGoalStatus(options.status);
const tokenBudget = normalizeGoalBudget(options.budget);

if (options.clear && (objective || status || tokenBudget !== undefined)) {
throw new Error("Use `--clear` by itself, optionally with `--thread-id`.");
}
if (options.fresh && options["thread-id"]) {
throw new Error("Choose either --fresh or --thread-id.");
}
if (options.resume && options.fresh) {
throw new Error("Choose either --resume or --fresh.");
}

const shouldCreate = Boolean(objective && !options.clear && !options.show);
const thread = await resolveGoalThread(cwd, workspaceRoot, options, shouldCreate ? objective : "");

if (!thread.id) {
outputCommandResult(
{ action: "show", threadId: null, goal: null },
renderGoalReport({ action: "show", threadId: null, goal: null }),
options.json
);
return;
}

if (options.clear) {
const result = await clearThreadGoal(cwd, thread.id);
if (!options["thread-id"]) {
storeGoalThreadId(workspaceRoot, null);
Comment on lines +934 to +935
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 Clear the stored goal id when that thread is cleared

When a user clears the current goal with an explicit --thread-id (the usage text allows this), the goal is removed from Codex but the plugin state still keeps goalThreadId because this branch skips storeGoalThreadId(workspaceRoot, null). Since resume resolution checks that config before task history, a later /codex:cli --resume or /codex:goal --show will keep targeting the cleared/stale goal thread instead of falling back to no goal or the latest task; clear the config when the cleared id matches the stored goal id.

Useful? React with 👍 / 👎.

}
outputCommandResult(
{ action: "clear", threadId: thread.id, cleared: Boolean(result.cleared) },
renderGoalReport({ action: "clear", threadId: thread.id, cleared: Boolean(result.cleared) }),
options.json
);
return;
}

if (options.show || (!objective && !status && tokenBudget === undefined)) {
const goal = await readThreadGoal(cwd, thread.id);
outputCommandResult(
{ action: "show", threadId: thread.id, goal },
renderGoalReport({ action: "show", threadId: thread.id, goal }),
options.json
);
return;
}

const goal = await setThreadGoal(
cwd,
thread.id,
buildGoalSetParams({
objective,
status,
tokenBudget
})
);
storeGoalThreadId(workspaceRoot, thread.id);

outputCommandResult(
{ action: thread.created ? "create" : "update", threadId: thread.id, goal },
renderGoalReport({ action: thread.created ? "create" : "update", threadId: thread.id, goal }),
options.json
);
}

async function handleTaskWorker(argv) {
const { options } = parseCommandInput(argv, {
valueOptions: ["cwd", "job-id"]
Expand Down Expand Up @@ -1000,6 +1178,12 @@ async function main() {
case "task":
await handleTask(argv);
break;
case "cli":
await handleTask(argv);
break;
case "goal":
await handleGoal(argv);
break;
case "task-worker":
await handleTaskWorker(argv);
break;
Expand Down
Loading