Skip to content
Merged
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
48 changes: 35 additions & 13 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,30 @@ function isActiveJobStatus(status) {
return status === "queued" || status === "running";
}

function getCurrentClaudeSessionId() {
return process.env[SESSION_ID_ENV] ?? null;
}

function filterJobsForCurrentClaudeSession(jobs) {
const sessionId = getCurrentClaudeSessionId();
if (!sessionId) {
return jobs;
}
return jobs.filter((job) => job.sessionId === sessionId);
}

function findLatestResumableTaskJob(jobs) {
return (
jobs.find(
(job) =>
job.jobClass === "task" &&
job.threadId &&
job.status !== "queued" &&
job.status !== "running"
) ?? null
);
}

async function waitForSingleJobSnapshot(cwd, reference, options = {}) {
const timeoutMs = Math.max(0, Number(options.timeoutMs) || DEFAULT_STATUS_WAIT_TIMEOUT_MS);
const pollIntervalMs = Math.max(100, Number(options.pollIntervalMs) || DEFAULT_STATUS_POLL_INTERVAL_MS);
Expand All @@ -310,17 +334,23 @@ async function waitForSingleJobSnapshot(cwd, reference, options = {}) {

async function resolveLatestTrackedTaskThread(cwd, options = {}) {
const workspaceRoot = resolveWorkspaceRoot(cwd);
const sessionId = getCurrentClaudeSessionId();
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot)).filter((job) => job.id !== options.excludeJobId);
const activeTask = jobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
const visibleJobs = filterJobsForCurrentClaudeSession(jobs);
const activeTask = visibleJobs.find((job) => job.jobClass === "task" && (job.status === "queued" || job.status === "running"));
if (activeTask) {
throw new Error(`Task ${activeTask.id} is still running. Use /codex:status before continuing it.`);
}

const trackedTask = jobs.find((job) => job.jobClass === "task" && job.status === "completed" && job.threadId);
const trackedTask = findLatestResumableTaskJob(visibleJobs);
if (trackedTask) {
return { id: trackedTask.threadId };
}

if (sessionId) {
return null;
}

return findLatestTaskThread(workspaceRoot);
}

Expand Down Expand Up @@ -862,17 +892,9 @@ function handleTaskResumeCandidate(argv) {

const cwd = resolveCommandCwd(options);
const workspaceRoot = resolveCommandWorkspace(options);
const sessionId = process.env[SESSION_ID_ENV] ?? null;
const jobs = sortJobsNewestFirst(listJobs(workspaceRoot));
const candidate =
jobs.find(
(job) =>
job.jobClass === "task" &&
job.threadId &&
job.status !== "queued" &&
job.status !== "running" &&
(!sessionId || job.sessionId === sessionId)
) ?? null;
const sessionId = getCurrentClaudeSessionId();
const jobs = filterJobsForCurrentClaudeSession(sortJobsNewestFirst(listJobs(workspaceRoot)));
const candidate = findLatestResumableTaskJob(jobs);

const payload = {
available: Boolean(candidate),
Expand Down
99 changes: 99 additions & 0 deletions tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,105 @@ test("task-resume-candidate returns the latest rescue thread from the current se
assert.equal(payload.candidate.threadId, "thr_current");
});

test("task --resume-last does not resume a task from another Claude session", () => {
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 otherEnv = {
...buildEnv(binDir),
CODEX_COMPANION_SESSION_ID: "sess-other"
};
const currentEnv = {
...buildEnv(binDir),
CODEX_COMPANION_SESSION_ID: "sess-current"
};

const firstRun = run("node", [SCRIPT, "task", "initial task"], {
cwd: repo,
env: otherEnv
});
assert.equal(firstRun.status, 0, firstRun.stderr);

const candidate = run("node", [SCRIPT, "task-resume-candidate", "--json"], {
cwd: repo,
env: currentEnv
});
assert.equal(candidate.status, 0, candidate.stderr);
assert.equal(JSON.parse(candidate.stdout).available, false);

const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
cwd: repo,
env: currentEnv
});
assert.equal(resume.status, 1);
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);

const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8"));
assert.equal(fakeState.lastTurnStart.threadId, "thr_1");
assert.equal(fakeState.lastTurnStart.prompt, "initial task");
});

test("task --resume-last ignores running tasks from other Claude sessions", () => {
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 stateDir = resolveStateDir(repo);
fs.mkdirSync(path.join(stateDir, "jobs"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "state.json"),
`${JSON.stringify(
{
version: 1,
config: { stopReviewGate: false },
jobs: [
{
id: "task-other-running",
status: "running",
title: "Codex Task",
jobClass: "task",
sessionId: "sess-other",
threadId: "thr_other",
summary: "Other session active task",
updatedAt: "2026-03-24T20:05:00.000Z"
}
]
},
null,
2
)}\n`,
"utf8"
);

const env = {
...buildEnv(binDir),
CODEX_COMPANION_SESSION_ID: "sess-current"
};
const status = run("node", [SCRIPT, "status", "--json"], {
cwd: repo,
env
});
assert.equal(status.status, 0, status.stderr);
assert.deepEqual(JSON.parse(status.stdout).running, []);

const resume = run("node", [SCRIPT, "task", "--resume-last", "follow up"], {
cwd: repo,
env
});
assert.equal(resume.status, 1);
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
});

test("session start hook exports the Claude session id and plugin data dir for later commands", () => {
const repo = makeTempDir();
const envFile = path.join(makeTempDir(), "claude-env.sh");
Expand Down