diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 7009ec86a..ff833bd1a 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -20,8 +20,8 @@ Selection guidance: Forwarding rules: - Use exactly one `Bash` call to invoke `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" task ...`. -- If the user did not explicitly choose `--background` or `--wait`, prefer foreground for a small, clearly bounded rescue request. -- 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. +- If the user did not explicitly choose `--background` or `--wait`, run the task in the foreground so the caller receives its final stdout. Do not infer background execution from task complexity. +- Add `--background` only when the user explicitly supplies it; treat `--wait` as an explicit foreground request. - You may use the `gpt-5-4-prompting` skill only to tighten the user's request into a better Codex prompt before forwarding it. - Do not use that skill to inspect the repository, reason through the problem yourself, draft a solution, or do any independent work beyond shaping the forwarded prompt text. - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 2da23498f..bd29a80f8 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -26,18 +27,38 @@ function defaultState() { }; } +function resolveRepositoryIdentity(workspaceRoot) { + try { + const commonDir = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { + cwd: workspaceRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).trim(); + const mainWorktree = execFileSync("git", ["--git-dir", commonDir, "worktree", "list", "--porcelain"], { + cwd: workspaceRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"] + }).match(/^worktree (.+)$/m)?.[1]; + return mainWorktree ?? commonDir; + } catch { + return null; + } +} + export function resolveStateDir(cwd) { const workspaceRoot = resolveWorkspaceRoot(cwd); - let canonicalWorkspaceRoot = workspaceRoot; + const repositoryIdentity = resolveRepositoryIdentity(workspaceRoot); + const stateIdentity = repositoryIdentity ?? workspaceRoot; + let canonicalStateIdentity = stateIdentity; try { - canonicalWorkspaceRoot = fs.realpathSync.native(workspaceRoot); + canonicalStateIdentity = fs.realpathSync.native(stateIdentity); } catch { - canonicalWorkspaceRoot = workspaceRoot; + canonicalStateIdentity = stateIdentity; } - const slugSource = path.basename(workspaceRoot) || "workspace"; + const slugSource = path.basename(stateIdentity) || "workspace"; const slug = slugSource.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "workspace"; - const hash = createHash("sha256").update(canonicalWorkspaceRoot).digest("hex").slice(0, 16); + const hash = createHash("sha256").update(canonicalStateIdentity).digest("hex").slice(0, 16); const pluginDataDir = process.env[PLUGIN_DATA_ENV]; const stateRoot = pluginDataDir ? path.join(pluginDataDir, "state") : FALLBACK_STATE_ROOT_DIR; return path.join(stateRoot, `${slug}-${hash}`); diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 0e91bfb50..f989c690f 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -25,7 +25,8 @@ Execution rules: Command selection: - Use exactly one `task` invocation per rescue handoff. -- If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. +- If the forwarded request omits `--background` and `--wait`, run `task` in the foreground. Do not infer background execution from task complexity; the caller expects the final stdout unchanged. +- If the forwarded request explicitly includes `--background`, strip it from the task text and add `--background` to `task`; if it includes `--wait`, strip it and keep `task` in the foreground. - If the forwarded request includes `--model`, normalize `spark` to `gpt-5.3-codex-spark` and pass it through to `task`. - If the forwarded request includes `--effort`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..cbd1fd788 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -127,8 +127,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(agent, /--resume/); assert.match(agent, /--fresh/); assert.match(agent, /thin forwarding wrapper/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); + assert.match(agent, /run the task in the foreground so the caller receives its final stdout/i); + assert.doesNotMatch(agent, /prefer background execution/i); assert.match(agent, /Use exactly one `Bash` call/i); assert.match(agent, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(agent, /Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`/i); @@ -148,8 +148,8 @@ test("rescue command absorbs continue semantics", () => { assert.match(runtimeSkill, /Leave `--effort` unset unless the user explicitly requests a specific effort/i); assert.match(runtimeSkill, /Leave model unset by default/i); assert.match(runtimeSkill, /Map `spark` to `--model gpt-5\.3-codex-spark`/i); - assert.match(runtimeSkill, /If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only/i); - assert.match(runtimeSkill, /Strip it before calling `task`/i); + assert.match(runtimeSkill, /run `task` in the foreground/i); + assert.match(runtimeSkill, /If the forwarded request explicitly includes `--background`/i); assert.match(runtimeSkill, /`--effort`: accepted values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`/i); assert.match(runtimeSkill, /Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own/i); assert.match(runtimeSkill, /If the Bash call fails or Codex cannot be invoked, return nothing/i); diff --git a/tests/state.test.mjs b/tests/state.test.mjs index 0f8f57cea..9cf4237de 100644 --- a/tests/state.test.mjs +++ b/tests/state.test.mjs @@ -4,16 +4,27 @@ import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; -import { makeTempDir } from "./helpers.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; import { resolveJobFile, resolveJobLogFile, resolveStateDir, resolveStateFile, saveState } from "../plugins/codex/scripts/lib/state.mjs"; test("resolveStateDir uses a temp-backed per-workspace directory", () => { const workspace = makeTempDir(); - const stateDir = resolveStateDir(workspace); + const previousPluginDataDir = process.env.CLAUDE_PLUGIN_DATA; + delete process.env.CLAUDE_PLUGIN_DATA; - assert.equal(stateDir.startsWith(os.tmpdir()), true); - assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); - assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + try { + const stateDir = resolveStateDir(workspace); + + assert.equal(stateDir.startsWith(os.tmpdir()), true); + assert.match(path.basename(stateDir), /.+-[a-f0-9]{16}$/); + assert.match(stateDir, new RegExp(`^${os.tmpdir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`)); + } finally { + if (previousPluginDataDir == null) { + delete process.env.CLAUDE_PLUGIN_DATA; + } else { + process.env.CLAUDE_PLUGIN_DATA = previousPluginDataDir; + } + } }); test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { @@ -40,6 +51,21 @@ test("resolveStateDir uses CLAUDE_PLUGIN_DATA when it is provided", () => { } }); +test("resolveStateDir shares state between a repository and its worktree", () => { + const repository = makeTempDir(); + const worktree = makeTempDir(); + initGitRepo(repository); + fs.writeFileSync(path.join(repository, "README.md"), "fixture\n", "utf8"); + run("git", ["add", "README.md"], { cwd: repository }); + run("git", ["commit", "-m", "fixture"], { cwd: repository }); + run("git", ["worktree", "add", "--detach", worktree, "HEAD"], { cwd: repository }); + + try { + assert.equal(resolveStateDir(worktree), resolveStateDir(repository)); + } finally { + run("git", ["worktree", "remove", "--force", worktree], { cwd: repository }); + } +}); test("saveState prunes dropped job artifacts when indexed jobs exceed the cap", () => { const workspace = makeTempDir(); const stateFile = resolveStateFile(workspace);