From 10febc498c11b53582ddaf501a0d2fec03458f41 Mon Sep 17 00:00:00 2001 From: qiunai Date: Fri, 22 May 2026 22:42:20 +0800 Subject: [PATCH 1/3] feat: resume codex tasks by thread id --- plugins/codex/CHANGELOG.md | 5 +++ plugins/codex/scripts/codex-companion.mjs | 43 +++++++++++++++-------- tests/runtime.test.mjs | 28 +++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index d647561b..657fd004 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Add `codex-companion task --resume-thread ` for routing a delegated + task continuation to an exact Codex thread. + ## 1.0.0 - Initial version of the Codex plugin for Claude Code diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..1cfe2833 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -77,7 +77,7 @@ 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 ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write] [--resume-thread |--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " 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]" @@ -461,11 +461,17 @@ async function executeTaskRun(request) { const taskMetadata = buildTaskRunMetadata({ prompt: request.prompt, - resumeLast: request.resumeLast + resumeLast: request.resumeLast || Boolean(request.resumeThreadId) }); - let resumeThreadId = null; - if (request.resumeLast) { + let resumeThreadId = request.resumeThreadId ?? null; + if (resumeThreadId) { + const id = String(resumeThreadId).trim(); + if (!id || /\s/.test(id)) { + throw new Error(`Invalid --resume-thread value: ${resumeThreadId}`); + } + resumeThreadId = id; + } else if (request.resumeLast) { const latestThread = await resolveLatestTrackedTaskThread(workspaceRoot, { excludeJobId: request.jobId }); @@ -476,7 +482,7 @@ async function executeTaskRun(request) { } if (!request.prompt && !resumeThreadId) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); + throw new Error("Provide a prompt, a prompt file, piped stdin, --resume-thread, or use --resume-last."); } const result = await runAppServerTurn(workspaceRoot, { @@ -598,7 +604,7 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, resumeThreadId, jobId }) { return { cwd, model, @@ -606,6 +612,7 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId prompt, write, resumeLast, + resumeThreadId, jobId }; } @@ -619,9 +626,9 @@ function readTaskPrompt(cwd, options, positionals) { return positionalPrompt || readStdinIfPiped(); } -function requireTaskRequest(prompt, resumeLast) { - if (!prompt && !resumeLast) { - throw new Error("Provide a prompt, a prompt file, piped stdin, or use --resume-last."); +function requireTaskRequest(prompt, resumeLast, resumeThreadId = null) { + if (!prompt && !resumeLast && !resumeThreadId) { + throw new Error("Provide a prompt, a prompt file, piped stdin, --resume-thread, or use --resume-last."); } } @@ -731,7 +738,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", "resume-thread"], booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" @@ -745,19 +752,25 @@ async function handleTask(argv) { const prompt = readTaskPrompt(cwd, options, positionals); const resumeLast = Boolean(options["resume-last"] || options.resume); + const resumeThreadId = typeof options["resume-thread"] === "string" && options["resume-thread"].trim() + ? options["resume-thread"].trim() + : null; const fresh = Boolean(options.fresh); - if (resumeLast && fresh) { - throw new Error("Choose either --resume/--resume-last or --fresh."); + if ((resumeLast || resumeThreadId) && fresh) { + throw new Error("Choose either --resume/--resume-last/--resume-thread or --fresh."); + } + if (resumeLast && resumeThreadId) { + throw new Error("Choose either --resume-last/--resume or --resume-thread, not both."); } const write = Boolean(options.write); const taskMetadata = buildTaskRunMetadata({ prompt, - resumeLast + resumeLast: resumeLast || Boolean(resumeThreadId) }); if (options.background) { ensureCodexAvailable(cwd); - requireTaskRequest(prompt, resumeLast); + requireTaskRequest(prompt, resumeLast, resumeThreadId); const job = buildTaskJob(workspaceRoot, taskMetadata, write); const request = buildTaskRequest({ @@ -767,6 +780,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + resumeThreadId, jobId: job.id }); const { payload } = enqueueBackgroundTask(cwd, job, request); @@ -785,6 +799,7 @@ async function handleTask(argv) { prompt, write, resumeLast, + resumeThreadId, jobId: job.id, onProgress: progress }), diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..3cb80cf9 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -369,6 +369,34 @@ test("task --resume-last resumes the latest persisted task thread", () => { assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); }); +test("task --resume-thread resumes the specified persisted task 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 firstRun = run("node", [SCRIPT, "task", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Resumed the prior run.\nFollow-up prompt accepted.\n"); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.threadId, "thr_1"); + assert.equal(fakeState.lastTurnStart.prompt, "follow up"); +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); From 28c1ad97b3b8d6ae14685623d0bb5526a0a397cd Mon Sep 17 00:00:00 2001 From: qiunai Date: Sat, 23 May 2026 09:31:58 +0800 Subject: [PATCH 2/3] feat: allow delegated tasks to request full access --- plugins/codex/CHANGELOG.md | 2 ++ plugins/codex/scripts/codex-companion.mjs | 37 +++++++++++++++++++---- tests/fake-codex-fixture.mjs | 11 +++++++ tests/runtime.test.mjs | 30 ++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/plugins/codex/CHANGELOG.md b/plugins/codex/CHANGELOG.md index 657fd004..046bcae1 100644 --- a/plugins/codex/CHANGELOG.md +++ b/plugins/codex/CHANGELOG.md @@ -4,6 +4,8 @@ - Add `codex-companion task --resume-thread ` for routing a delegated task continuation to an exact Codex thread. +- Add `codex-companion task --full-access` and `--sandbox ` for delegated + tasks that need an explicit Codex filesystem sandbox. ## 1.0.0 diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 1cfe2833..4a053a08 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -77,7 +77,7 @@ 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 ] [focus text]", - " node scripts/codex-companion.mjs task [--background] [--write] [--resume-thread |--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs task [--background] [--write|--full-access] [--sandbox ] [--resume-thread |--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", " 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]" @@ -491,7 +491,7 @@ async function executeTaskRun(request) { defaultPrompt: resumeThreadId ? DEFAULT_CONTINUE_PROMPT : "", model: request.model, effort: request.effort, - sandbox: request.write ? "workspace-write" : "read-only", + sandbox: request.sandbox || (request.write ? "workspace-write" : "read-only"), onProgress: request.onProgress, persistThread: true, threadName: resumeThreadId ? null : buildPersistentTaskThreadName(request.prompt || DEFAULT_CONTINUE_PROMPT) @@ -604,13 +604,14 @@ function buildTaskJob(workspaceRoot, taskMetadata, write) { }); } -function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, resumeThreadId, jobId }) { +function buildTaskRequest({ cwd, model, effort, prompt, write, sandbox, resumeLast, resumeThreadId, jobId }) { return { cwd, model, effort, prompt, write, + sandbox, resumeLast, resumeThreadId, jobId @@ -632,6 +633,27 @@ function requireTaskRequest(prompt, resumeLast, resumeThreadId = null) { } } +function resolveTaskSandbox(options, write) { + const requested = typeof options.sandbox === "string" && options.sandbox.trim() + ? options.sandbox.trim() + : null; + const envFullAccess = process.env.CODEX_CLAUDE_COLLABORATION_FULL_ACCESS === "1" + || process.env.CODEX_HANDOFF_FULL_ACCESS === "1"; + if (options["full-access"] || envFullAccess) { + if (requested && requested !== "danger-full-access") { + throw new Error("Choose either --full-access/CODEX_CLAUDE_COLLABORATION_FULL_ACCESS or a non-full-access --sandbox, not both."); + } + return "danger-full-access"; + } + if (requested) { + if (!["read-only", "workspace-write", "danger-full-access"].includes(requested)) { + throw new Error("Invalid --sandbox value. Expected read-only, workspace-write, or danger-full-access."); + } + return requested; + } + return write ? "workspace-write" : "read-only"; +} + async function runForegroundCommand(job, runner, options = {}) { const { logFile, progress } = createTrackedProgress(job, { logFile: options.logFile, @@ -738,8 +760,8 @@ async function handleReview(argv) { async function handleTask(argv) { const { options, positionals } = parseCommandInput(argv, { - valueOptions: ["model", "effort", "cwd", "prompt-file", "resume-thread"], - booleanOptions: ["json", "write", "resume-last", "resume", "fresh", "background"], + valueOptions: ["model", "effort", "cwd", "prompt-file", "resume-thread", "sandbox"], + booleanOptions: ["json", "write", "full-access", "resume-last", "resume", "fresh", "background"], aliasMap: { m: "model" } @@ -762,7 +784,8 @@ async function handleTask(argv) { if (resumeLast && resumeThreadId) { throw new Error("Choose either --resume-last/--resume or --resume-thread, not both."); } - const write = Boolean(options.write); + const write = Boolean(options.write || options["full-access"] || options.sandbox === "workspace-write" || options.sandbox === "danger-full-access"); + const sandbox = resolveTaskSandbox(options, write); const taskMetadata = buildTaskRunMetadata({ prompt, resumeLast: resumeLast || Boolean(resumeThreadId) @@ -779,6 +802,7 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, resumeThreadId, jobId: job.id @@ -798,6 +822,7 @@ async function handleTask(argv) { effort, prompt, write, + sandbox, resumeLast, resumeThreadId, jobId: job.id, diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index debcadce..6a19ee6e 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -297,6 +297,12 @@ rl.on("line", (line) => { throw new Error("thread/start.persistFullHistory requires experimentalApi capability"); } const thread = nextThread(state, message.params.cwd, message.params.ephemeral); + state.lastThreadStart = { + cwd: message.params.cwd, + sandbox: message.params.sandbox ?? null, + ephemeral: message.params.ephemeral ?? null + }; + saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); send({ method: "thread/started", params: { thread: { id: thread.id } } }); break; @@ -330,6 +336,11 @@ rl.on("line", (line) => { } const thread = ensureThread(state, message.params.threadId); thread.updatedAt = now(); + state.lastThreadResume = { + threadId: message.params.threadId, + cwd: message.params.cwd, + sandbox: message.params.sandbox ?? null + }; saveState(state); send({ id: message.id, result: { thread: buildThread(thread), model: message.params.model || "gpt-5.4", modelProvider: "openai", serviceTier: null, cwd: thread.cwd, approvalPolicy: "never", sandbox: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false }, reasoningEffort: null } }); break; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 3cb80cf9..83949d65 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -397,6 +397,36 @@ test("task --resume-thread resumes the specified persisted task thread", () => { assert.equal(fakeState.lastTurnStart.prompt, "follow up"); }); +test("task --full-access forwards danger-full-access sandbox", () => { + 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 firstRun = run("node", [SCRIPT, "task", "--full-access", "initial task"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(firstRun.status, 0, firstRun.stderr); + + let fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadStart.sandbox, "danger-full-access"); + + const result = run("node", [SCRIPT, "task", "--resume-thread", "thr_1", "--full-access", "follow up"], { + cwd: repo, + env: buildEnv(binDir) + }); + assert.equal(result.status, 0, result.stderr); + + fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); + assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access"); +}); + test("task-resume-candidate returns the latest rescue thread from the current session", () => { const workspace = makeTempDir(); const stateDir = resolveStateDir(workspace); From bf427e84283b8071407e0533099bba49a9fa8e84 Mon Sep 17 00:00:00 2001 From: qiunai Date: Sun, 24 May 2026 01:18:41 +0800 Subject: [PATCH 3/3] fix: forward full access to app-server turns --- plugins/codex/scripts/lib/codex.mjs | 9 +++++++++ tests/fake-codex-fixture.mjs | 16 +++++++++------- tests/runtime.test.mjs | 2 ++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f2fe88bd..a0952cea 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -76,6 +76,13 @@ function buildResumeParams(threadId, cwd, options = {}) { }; } +function buildTurnSandboxPolicy(sandbox) { + if (sandbox === "danger-full-access") { + return { type: "dangerFullAccess" }; + } + return null; +} + /** @returns {UserInput[]} */ function buildTurnInput(prompt) { return [{ type: "text", text: prompt, text_elements: [] }]; @@ -1007,6 +1014,8 @@ export async function runAppServerTurn(cwd, options = {}) { input: buildTurnInput(prompt), model: options.model ?? null, effort: options.effort ?? null, + approvalPolicy: options.approvalPolicy ?? "never", + sandboxPolicy: buildTurnSandboxPolicy(options.sandbox), outputSchema: options.outputSchema ?? null }), { onProgress: options.onProgress } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 6a19ee6e..3723ba21 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -386,13 +386,15 @@ rl.on("line", (line) => { .join("\\n"); const turnId = nextTurnId(state); thread.updatedAt = now(); - state.lastTurnStart = { - threadId: message.params.threadId, - turnId, - model: message.params.model ?? null, - effort: message.params.effort ?? null, - prompt - }; + state.lastTurnStart = { + threadId: message.params.threadId, + turnId, + model: message.params.model ?? null, + effort: message.params.effort ?? null, + approvalPolicy: message.params.approvalPolicy ?? null, + sandboxPolicy: message.params.sandboxPolicy ?? null, + prompt + }; saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId) } }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 83949d65..4c228ec6 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -425,6 +425,8 @@ test("task --full-access forwards danger-full-access sandbox", () => { fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); assert.equal(fakeState.lastThreadResume.threadId, "thr_1"); assert.equal(fakeState.lastThreadResume.sandbox, "danger-full-access"); + assert.equal(fakeState.lastTurnStart.approvalPolicy, "never"); + assert.deepEqual(fakeState.lastTurnStart.sandboxPolicy, { type: "dangerFullAccess" }); }); test("task-resume-candidate returns the latest rescue thread from the current session", () => {