diff --git a/README.md b/README.md index d8758ee55..9a2fa5e86 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ they already have. | --- | --- | --- | | [`/codex:review`](#codexreview) | read-only Codex review of your current work | `--wait`, `--background`, `--base `, `--scope `, `--model `, `--effort ` | | [`/codex:adversarial-review`](#codexadversarial-review) | steerable review that challenges the approach | same as `/codex:review`, plus free-form focus text | -| [`/codex:rescue`](#codexrescue) | delegate investigation or a fix to Codex | `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model`, `--effort`, `--write`, `--sandbox `, `--read-root ` | +| [`/codex:rescue`](#codexrescue) | delegate investigation or a fix to Codex | `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--ephemeral`, `--model`, `--effort`, `--write`, `--sandbox `, `--read-root ` | | [`/codex:transfer`](#codextransfer) | turn this Claude session into a resumable Codex thread | `--source ` | | [`/codex:status`](#codexstatus) | show active and recent Codex jobs | `[job-id]`, `--wait`, `--timeout-ms `, `--all` | | [`/codex:result`](#codexresult) | show the stored output of a finished job | `[job-id]` | @@ -192,7 +192,7 @@ Use it when you want Codex to: > [!NOTE] > Depending on the task and the model you choose these tasks might take a long time and it's generally recommended to force the task to be in the background or move the agent to the background. -It supports `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--model `, `--effort `, `--write`, `--sandbox `, and repeatable `--read-root `. If you omit the resume flags, the plugin can offer to continue the latest rescue thread for this repo. +It supports `--background`, `--wait`, `--resume`, `--resume-thread `, `--fresh`, `--ephemeral`, `--model `, `--effort `, `--write`, `--sandbox `, and repeatable `--read-root `. If you omit the resume flags, the plugin can offer to continue the latest rescue thread for this repo. Examples: @@ -221,6 +221,7 @@ 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 - `--resume`/`--resume-last` continues the newest thread for this repository; `--resume-thread ` continues one specific thread (the id is printed by `/codex:status` and `/codex:result`). `--resume`, `--resume-thread`, and `--fresh` are mutually exclusive. +- `--ephemeral` runs without persisting the Codex thread: nothing is added to Codex's Recent list, and there is no thread to come back to. Useful for disposable, fire-and-forget work — many parallel subtasks from an orchestrating agent, say — where the persistent threads are only noise. It is refused together with `--resume`, `--resume-last` and `--resume-thread`, an ephemeral run is never offered as a `--resume-last` candidate, and `/codex:status` and `/codex:result` stop printing a `codex resume` line for it. Without the flag nothing changes: threads persist exactly as before. - `--sandbox` applies to `/codex:rescue` only; the review commands stay read-only. It takes precedence over `--write` and counts only before the task text. Rescue runs edit files inside the repository by default (`workspace-write`); `read-only` blocks edits, and `danger-full-access` disables the Codex sandbox entirely, so Codex can write outside the repository and use the network without asking. Reserve it for tasks the sandbox blocks. - a resumed thread keeps the sandbox it was started with while the plugin's shared app-server still holds it, which is the normal case inside one Claude Code session (Codex CLI 0.153.2 applies a new mode only when it loads the thread again from disk). `task` refuses a resume whose sandbox differs from what the app-server reports; resume with the same `--sandbox`, or start a new thread with `--fresh`. - each `--read-root ` must name an existing directory and opts into an OS-enforced permission profile that denies local command reads outside the listed directories and Codex's minimal runtime paths @@ -428,6 +429,7 @@ Broker and background-job lifecycle: | [#774](https://github.com/openai/codex-plugin-cc/pull/774) | `status --wait` prints its timeout and exits non-zero, instead of looking like a finished status check | | [#773](https://github.com/openai/codex-plugin-cc/pull/773) | a broker connect that never completes is given up on after 2s and falls back to a direct app-server (the probe half of that PR is not taken: ours already bounds each attempt *and* reports why it failed) | | [#776](https://github.com/openai/codex-plugin-cc/pull/776) | Windows teardown decides on the root's liveness instead of taskkill's message: a process already gone costs no `taskkill` at all, and a `taskkill` that reports failure only because a short-lived descendant exited mid-walk no longer throws at the caller (its broker-endpoint and shutdown-timeout changes are not taken — one is a no-op here, the other is behind what this fork already does) | +| [#779](https://github.com/openai/codex-plugin-cc/pull/779) | `--ephemeral` on `task`, so a disposable run does not leave a persistent Codex thread behind (extended here to refuse this fork's `--resume-thread` as well) | Commands and flags: diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 60fc71f30..89803aa84 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -35,10 +35,11 @@ Forwarding rules: - Preserve every `--read-root ` pair as a runtime control and do not include either token in the task text you pass through. - When forwarding both scoped roots and a write-capable sandbox (`--write` or `--sandbox workspace-write`), include an approved read root that covers the workspace directory. - Otherwise default to a write-capable Codex run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. -- Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls and do not include them in the task text you pass through. +- Treat `--resume`, `--resume-thread `, `--fresh`, and `--ephemeral` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. - `--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`. - `--fresh` means do not add `--resume-last`. +- `--ephemeral` means pass the flag through and do not add `--resume-last`: the run is not persisted, so there is no thread to resume and `task` refuses the combination. - If the user is clearly asking to continue prior Codex work in this repository, such as "continue", "keep going", "resume", "apply the top fix", or "dig deeper", add `--resume-last` unless `--fresh` or `--resume-thread ` is present. - Otherwise forward the task as a fresh `task` run. - Preserve the user's task text as-is apart from stripping routing flags. diff --git a/plugins/codex/commands/rescue.md b/plugins/codex/commands/rescue.md index dbc0f3892..9006a5cc7 100644 --- a/plugins/codex/commands/rescue.md +++ b/plugins/codex/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the Codex rescue subagent -argument-hint: "[--background|--wait] [--resume|--resume-thread |--fresh] [--model ] [--effort ] [--sandbox ] [--read-root ...] [what Codex should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--resume-thread |--fresh|--ephemeral] [--model ] [--effort ] [--sandbox ] [--read-root ...] [what Codex should investigate, solve, or continue]" allowed-tools: Bash(node:*), AskUserQuestion, Agent --- @@ -24,6 +24,7 @@ Execution mode: - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - If the request includes `--resume-thread `, do not ask whether to continue. The user already chose the exact Codex thread. +- If the request includes `--ephemeral`, do not ask whether to continue: an ephemeral run cannot resume anything. Preserve the flag for the forwarded `task` call and keep it out of the natural-language task text. - Otherwise, before starting Codex, check for a resumable rescue thread from this Claude session by running: ```bash diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 871243be3..bec6052ad 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -90,7 +90,8 @@ function printUsage() { " node scripts/codex-companion.mjs setup [--enable-review-gate|--disable-review-gate] [--json]", " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [--model ] [--effort ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--sandbox ] [--read-root ...] [--resume-last|--resume|--resume-thread |--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--ephemeral] [--sandbox ] [--read-root ...] [--resume-last|--resume|--resume-thread |--fresh] [--model ] [--effort ] [prompt]", + " --ephemeral: run without persisting the Codex thread. Ephemeral tasks cannot be resumed and do not appear in Codex Recent. Cannot be combined with --resume/--resume-last/--resume-thread.", " node scripts/codex-companion.mjs transfer [--source ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", @@ -368,6 +369,7 @@ function findLatestResumableTaskJob(jobs) { (job) => job.jobClass === "task" && job.threadId && + !job.ephemeral && job.status !== "queued" && job.status !== "running" ) ?? null @@ -547,6 +549,7 @@ async function executeTaskRun(request) { throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last / --resume-thread ."); } + const persistThread = resumeThreadId ? true : !request.ephemeral; const result = await runAppServerTurn(workspaceRoot, { resumeThreadId, prompt: request.prompt, @@ -557,8 +560,8 @@ async function executeTaskRun(request) { readRoots: request.readRoots, write: request.write, onProgress: request.onProgress, - persistThread: true, - threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) + persistThread, + threadName: persistThread ? buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) : null }); const rawOutput = typeof result.finalMessage === "string" ? result.finalMessage : ""; @@ -597,7 +600,8 @@ async function executeTaskRun(request) { errorMessage: failureMessage || null, jobTitle: taskMetadata.title, jobClass: "task", - write: Boolean(request.write) + write: Boolean(request.write), + ephemeral: !persistThread }; } @@ -673,7 +677,19 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, readRoots, resumeLast, resumeThread = null, jobId }) { +function buildTaskRequest({ + cwd, + model, + effort, + prompt, + write, + sandbox, + readRoots, + resumeLast, + resumeThread = null, + jobId, + ephemeral +}) { return { cwd, model, @@ -684,7 +700,8 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, readRoot readRoots, resumeLast, resumeThread, - jobId + jobId, + ephemeral: Boolean(ephemeral) }; } @@ -879,7 +896,7 @@ async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["model", "effort", "cwd", "prompt-file", "sandbox", "resume-thread"], multiValueOptions: ["read-root"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background", "ephemeral"], leadingOnlyOptions: ["sandbox"], aliasMap: { m: "model" @@ -915,6 +932,14 @@ async function handleTask(argv) { "--write requires an approved --read-root that covers the workspace directory; the same applies to --sandbox workspace-write." ); } + const ephemeral = Boolean(options.ephemeral); + // This fork also has --resume-thread, which resumes by id: an ephemeral thread is never + // persisted, so there is nothing for either form of resume to come back to. + if (ephemeral && (resumeLast || resumeThread)) { + throw new Error( + "--ephemeral cannot be combined with --resume/--resume-last/--resume-thread. Ephemeral tasks are not persisted and cannot be resumed." + ); + } const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast: resumeLast || Boolean(resumeThread) @@ -935,7 +960,8 @@ async function handleTask(argv) { readRoots, resumeLast, resumeThread, - jobId: job.id + jobId: job.id, + ephemeral }); const { payload } = enqueueBackgroundTask(cwd, job, request); outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); @@ -957,6 +983,7 @@ async function handleTask(argv) { resumeLast, resumeThread, jobId: job.id, + ephemeral, onProgress: progress }), { json: options.json } diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 93b9a2d3b..5c95e9328 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -100,7 +100,7 @@ function escapeMarkdownCell(value) { } function formatCodexResumeCommand(job) { - if (!job?.threadId) { + if (!job?.threadId || job.ephemeral) { return null; } return `codex resume ${job.threadId}`; @@ -393,7 +393,8 @@ export function renderJobStatusReport(job, options = {}) { } export function renderStoredJobResult(job, storedJob) { - const threadId = storedJob?.threadId ?? job.threadId ?? null; + const isEphemeral = Boolean(storedJob?.ephemeral ?? job.ephemeral); + const threadId = isEphemeral ? null : (storedJob?.threadId ?? job.threadId ?? null); const resumeCommand = threadId ? `codex resume ${threadId}` : null; if (isStructuredReviewStoredResult(storedJob) && storedJob?.rendered) { const output = storedJob.rendered.endsWith("\n") ? storedJob.rendered : `${storedJob.rendered}\n`; diff --git a/plugins/codex/scripts/lib/tracked-jobs.mjs b/plugins/codex/scripts/lib/tracked-jobs.mjs index 4f95084d4..1dcecfeb4 100644 --- a/plugins/codex/scripts/lib/tracked-jobs.mjs +++ b/plugins/codex/scripts/lib/tracked-jobs.mjs @@ -374,6 +374,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + ephemeral: Boolean(execution.ephemeral), pid: null, phase: completionStatus === "completed" ? "done" : "failed", completedAt, @@ -386,6 +387,7 @@ export async function runTrackedJob(job, runner, options = {}) { status: completionStatus, threadId: execution.threadId ?? null, turnId: execution.turnId ?? null, + ephemeral: Boolean(execution.ephemeral), summary: execution.summary, phase: completionStatus === "completed" ? "done" : "failed", pid: null, diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 89755ff36..9172cad67 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -138,7 +138,13 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /--resume-thread /); assert.match(agent, /Preserve every `--read-root `/i); assert.match(agent, /thin forwarding wrapper/i); - assert.match(agent, /Treat `--resume`, `--resume-thread `, and `--fresh` as routing controls/i); + assert.match( + agent, + /Treat `--resume`, `--resume-thread `, `--fresh`, and `--ephemeral` as routing controls/i + ); + // The flag is worthless to anyone who only ever types /codex:rescue unless the wrapper forwards + // it, so the contract is asserted on both sides. + assert.match(agent, /`--ephemeral` means pass the flag through and do not add `--resume-last`/i); assert.match(agent, /`--resume-thread ` means pass that exact routing pair through and do not add `--resume-last`/i); assert.match(agent, /prefer foreground for a small, clearly bounded rescue request/i); assert.match(agent, /If the user did not explicitly choose `--background` or `--wait` and the task looks complicated, open-ended, multi-step, or likely to keep Codex running for a long time, prefer background execution/i); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index ddb9f7620..a95b6b111 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -5917,3 +5917,147 @@ test("a retained orphan with an unreadable timestamp does not pin the broker for // has to count as expired: "cannot tell" must not mean "protected forever". assert.equal(JSON.parse(fs.readFileSync(path.join(stateDir, "state.json"), "utf8")).jobs[0].status, "failed"); }); + +test("task --ephemeral runs without persisting a Codex thread", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "disposable check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 1); + assert.equal(fakeState.threads[0].ephemeral, true); + assert.equal( + fakeState.threads.filter((thread) => !thread.ephemeral).length, + 0, + "an ephemeral task must not create a persistent (Recent-visible) thread" + ); +}); + +test("task without --ephemeral still persists a Codex thread (unchanged default)", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const result = run("node", [SCRIPT, "task", "conversational check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 1); + assert.equal(fakeState.threads[0].ephemeral, false); +}); + +test("repeated task --ephemeral runs stay independent and create zero persistent threads", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + for (let i = 0; i < 3; i += 1) { + const result = run("node", [SCRIPT, "task", "--ephemeral", `disposable check ${i}`], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Handled the requested task/); + } + + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.threads.length, 3); + assert.equal(fakeState.threads.every((thread) => thread.ephemeral), true); + assert.equal( + fakeState.threads.filter((thread) => !thread.ephemeral).length, + 0, + "N parallel/sequential ephemeral tasks must add 0 persistent threads" + ); +}); + +test("task --ephemeral rejects --resume-last", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "--resume-last", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--ephemeral cannot be combined with --resume\/--resume-last/); +}); + +test("task --ephemeral rejects --resume", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "--resume", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--ephemeral cannot be combined with --resume\/--resume-last/); +}); + +test("a completed ephemeral task cannot be picked up by a later --resume-last", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + + const firstRun = run("node", [SCRIPT, "task", "--ephemeral", "disposable check"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(resume.status, 1); + assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./); +}); + +test("task --ephemeral rejects --resume-thread", () => { + // This fork's third resume form. It resumes by thread id, which an ephemeral run never + // produces, so the same refusal has to cover it — upstream's check knows only --resume-last. + const repo = makeTempDir(); + const binDir = makeTempDir(); + installFakeCodex(binDir); + initGitRepo(repo); + + const result = run("node", [SCRIPT, "task", "--ephemeral", "--resume-thread", "thr_1", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--ephemeral cannot be combined with --resume\/--resume-last\/--resume-thread/); +});