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
4 changes: 2 additions & 2 deletions plugins/codex/agents/codex-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 26 additions & 5 deletions plugins/codex/scripts/lib/state.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

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 Cache repository identity lookups

When a task emits progress, paths like upsertJob/saveState and resolveJobFile reach resolveStateDir repeatedly, so this call now synchronously runs the Git identity lookup (including git worktree list) several times per state update. In foreground or background tasks with many progress events, those subprocesses block the Node process and can make status/log updates noticeably slow; compute the repository identity once per command or cache it per workspace/common-dir.

Useful? React with 👍 / 👎.

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}`);
Expand Down
3 changes: 2 additions & 1 deletion plugins/codex/skills/codex-cli-runtime/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
8 changes: 4 additions & 4 deletions tests/commands.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
36 changes: 31 additions & 5 deletions tests/state.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
Expand Down