openai / openai/codex-plugin-cc

`/codex:transfer` is broken on Windows: false "did not record an imported thread" error, and no-arg auto-detection always fails

Open
#514 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
33.3k
Forks
2.3k
PR merge metrics
No merged PRs in 30d

Description

/codex:transfer is broken on Windows: false "did not record an imported thread" error, and no-arg auto-detection always fails

Summary

On Windows, /codex:transfer never works out of the box, due to two independent bugs:

  1. No-arg auto-detection always fails with Could not identify the current Claude transcript. Retry with --source <path-to-claude-jsonl>. — because Claude Code does not inject CODEX_COMPANION_TRANSCRIPT_PATH and there is no fallback.
  2. Even with --source, it reports a false failure: Codex reported that the Claude import completed, but did not record an imported thread. — even though the import succeeds and a valid record is written to ~/.codex/external_agent_session_imports.json. Users never receive their codex resume <id> line and silently accumulate one orphaned thread per attempt.

Environment

  • Plugin: codex@openai-codex v1.0.6
  • Codex CLI: codex-cli 0.144.2
  • Node: v24.15.0
  • OS: Windows 11 (x64)

Bug 1 — no-arg transcript auto-detection always fails on Windows

scripts/lib/claude-session-transfer.mjsresolveClaudeSessionPath requires either --source or the CODEX_COMPANION_TRANSCRIPT_PATH env var, and throws otherwise:

const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
if (!requestedPath) {
  throw new Error("Could not identify the current Claude transcript. Retry with --source <path-to-claude-jsonl>.");
}

In practice the env var is not set for the plugin process, so bare /codex:transfer always fails. There is no fallback to locate the current transcript.

Suggested fix

Fall back to the most-recently-modified .jsonl under ~/.claude/projects (the current session's transcript is being actively appended to, so it is the newest file):

function findLatestTranscript() {
  let projectsRoot;
  try {
    projectsRoot = fs.realpathSync(CLAUDE_PROJECTS_DIR);
  } catch {
    return null;
  }
  let best = null;
  let bestMtime = -Infinity;
  let dirents;
  try {
    dirents = fs.readdirSync(projectsRoot, { withFileTypes: true });
  } catch {
    return null;
  }
  for (const dirent of dirents) {
    if (!dirent.isDirectory()) continue;
    const dir = path.join(projectsRoot, dirent.name);
    let files;
    try {
      files = fs.readdirSync(dir);
    } catch {
      continue;
    }
    for (const file of files) {
      if (!file.endsWith(".jsonl")) continue;
      const full = path.join(dir, file);
      try {
        const stat = fs.statSync(full);
        if (stat.mtimeMs > bestMtime) {
          bestMtime = stat.mtimeMs;
          best = full;
        }
      } catch {
        // ignore unreadable entries
      }
    }
  }
  return best;
}

export function resolveClaudeSessionPath(cwd, options = {}) {
  const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV];
  const sourcePath = requestedPath
    ? resolveUserPath(cwd, requestedPath)
    : findLatestTranscript();
  if (!sourcePath) {
    throw new Error("Could not identify the current Claude transcript. Retry with --source <path-to-claude-jsonl>.");
  }
  // …unchanged: .jsonl check, realpathSync, projects-dir containment check…
}

(A more precise variant could prefer the project dir whose name encodes cwd, but "newest transcript overall" reliably resolves to the live session.)


Bug 2 — post-import lookup never matches on Windows (false "did not record" error)

scripts/lib/codex.mjsimportedThreadIdForSource(sourcePath) looks up the record it just wrote using two equality checks that both fail on Windows:

const canonicalSource = fs.realpathSync(sourcePath);
const contentSha256 = sourceContentSha256(canonicalSource);
const match = records
  .filter(
    (record) =>
      record?.source_path === canonicalSource &&      // (a)
      record?.content_sha256 === contentSha256 &&      // (b)
      typeof record?.imported_thread_id === "string"
  )
  .at(-1);

(a) Extended-length path prefix mismatch. The ledger (written by the Codex binary) stores source_path with the Windows \\?\ prefix, but fs.realpathSync() here returns the path without it, so the strings never match:

ledger source_path : \\?\C:\Users\<user>\.claude\projects\<proj>\<id>.jsonl
realpathSync()     :     C:\Users\<user>\.claude\projects\<proj>\<id>.jsonl

(b) Live-file content-hash race. The transcript .jsonl is a live, append-only file. content_sha256 is stored at import time, but this function re-hashes the file after the import — by then the transcript has grown (the transfer turn is written to it), so the hash differs.

Either alone forces a null result → the false error, despite the thread being created and recorded.

Suggested fix

Normalize paths before comparing (strip \\?\; on Windows unify separators + case), and treat the content hash as a preference, not a hard requirement:

const records = Array.isArray(ledger?.records) ? ledger.records : [];

const normalizePath = (p) => {
  if (typeof p !== "string") return "";
  let s = p.replace(/^\\\\\?\\/, "").replace(/^\/\/\?\//, "");
  if (process.platform === "win32") s = s.replace(/\//g, "\\").toLowerCase();
  return s;
};

const target = normalizePath(canonicalSource);
const bySource = records.filter(
  (record) =>
    normalizePath(record?.source_path) === target &&
    typeof record?.imported_thread_id === "string"
);

// Transcript is live/append-only, so its hash can change between import and this
// lookup. Prefer an exact content-hash match, else the newest import.
const exact = bySource.filter((record) => record.content_sha256 === contentSha256).at(-1);
const match = exact ?? bySource.at(-1);
return match?.imported_thread_id ?? null;

Reproduction

  1. On Windows, run bare /codex:transfer from an active Claude Code session → Bug 1 ("Could not identify the current Claude transcript").
  2. Retry with --source <path-to-jsonl>Bug 2 ("did not record an imported thread").
  3. Inspect ~/.codex/external_agent_session_imports.json: a new record with a valid imported_thread_id is present, and source_path starts with \\?\.
  4. codex resume <that id> works fine — confirming the import itself succeeded and both errors are false negatives.

Result after both fixes

Bare /codex:transfer on Windows auto-detects the current session, imports it, and prints the expected codex resume <id> line — no arguments and no false error.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in scripts/lib/claude-session-transfer.mjs at resolveClaudeSessionPath and scripts/lib/codex.mjs at importedThreadIdForSource. Reproduce both Windows cases from the issue, then verify that bare /codex:transfer finds the active transcript, --source recognizes the ledger record, and the command prints a working codex resume ID.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.