Repository: openai/codex-plugin-cc (2026-04-01T08:09:41Z)

Issue:
task --resume-last ignores current-session scoping and can resume another Claude session's task
## Summary

`task-resume-candidate --json` is scoped to the current Claude session, but `task --resume-last` is not. In a multi-session workspace, the CLI can report that there is no resumable task for the current session and then still resume a different session's persisted Codex thread.

The same helper also lets an active task from another Claude session block `--resume-last`, even when `/codex:status` for the current session shows no running jobs.

## Reproduction

1. Start a repository with the plugin state directory available.
2. Run one successful `task` command under Claude session `sess-other` so it persists a completed task thread.
3. In a separate Claude session `sess-current`, run:
   - `node scripts/codex-companion.mjs task-resume-candidate --json`
   - `node scripts/codex-companion.mjs task --resume-last "follow up"`
4. Observe that `task-resume-candidate` reports `available: false`, but `--resume-last` still resumes the older thread from `sess-other`.
5. Alternatively, leave a `running` task owned by `sess-other` in state and run `task --resume-last` from `sess-current`.

## Expected

- Implicit resume selection should use the same current-session visibility rules as `task-resume-candidate`.
- If `CODEX_COMPANION_SESSION_ID` is set and the current session has no resumable task, `task --resume-last` should fail with "No previous Codex task thread was found for this repository.".
- A running task from another session should not block `--resume-last` for the current session.

## Actual

- `task-resume-candidate` says there is no resumable task in the current session.
- `task --resume-last` still resumes another session's completed thread.
- A running task from another session can also block `--resume-last` with `Task ... is still running. Use /codex:status before continuing it.` even though `/codex:status` for the current session shows no running jobs.

## Suspected Cause

`resolveLatestTrackedTaskThread(...)` scans all persisted task jobs in the workspace and only excludes the current job id. It does not apply current-session filtering before choosing a completed thread or before checking for active tasks.

## Suggested Fix

Thread the same session-aware filtering used by `task-resume-candidate` into the implicit `--resume-last` path, and only fall back to `findLatestTaskThread(...)` when there is no Claude session id in scope.



Discussion:

Patch:
diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs
index 201d1c7a..4471834d 100644
--- a/plugins/codex/scripts/codex-companion.mjs
+++ b/plugins/codex/scripts/codex-companion.mjs
@@ -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);
@@ -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);
 }
 
@@ -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),


Test patch:
diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs
index 6000c892..fea3fcf8 100644
--- a/tests/runtime.test.mjs
+++ b/tests/runtime.test.mjs
@@ -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");