DEV Community

Cover image for OpenAI's Codex plugin told me my session transfer failed. It hadn't.
Andrew Avery
Andrew Avery

Posted on

OpenAI's Codex plugin told me my session transfer failed. It hadn't.

I was two hours into a refactor when Claude Code warned me I was running low on tokens. Annoying, but solvable: OpenAI ships an official plugin, codex-plugin-cc, with /codex:transfer for exactly this. Hand the live session to Codex, keep the conversation, keep working.

I ran it. It said:

Codex reported that the Claude import completed, but did not record
an imported thread.
Enter fullscreen mode Exit fullscreen mode

So I opened Codex to start fresh — and the thread was already there. Full turn history. Continuable.

The transfer had worked. The plugin just didn't know it. This is the story of finding out why, and the three other things I broke my head on along the way.

The symptom, and the shape of it

Once I knew the message was wrong, the question was whether it was wrong sometimes or always. On my machine: always. Every transfer, every session size, every project — the thread appeared in Codex, and the plugin reported failure anyway.

Three issues described the same thing: #417, #514, and #513 — since closed, on 2026-08-11, as a duplicate of a newer report, #618. At the time I found them, none had a reply. I've since answered all three myself, with the fix below.

A consistent failure is a gift. It means the bug is structural, not a race — and structural bugs are findable by reading.

Following the state

Codex keeps its own records, and both of them turned out to be readable.

The first is a SQLite database at ~/.codex/state_5.sqlite, with a threads table carrying id, created_at, title, and cwd. That's the same store the Codex UI reads — ground truth for whether a thread got created. I opened it with a read-only URI so I could never corrupt live state while Codex was mid-write:

sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
Enter fullscreen mode Exit fullscreen mode

SELECT MAX(created_at) FROM threads before the import, the same query after, and the answer was unambiguous. A new thread, with the right title, every time the plugin claimed nothing had been recorded.

The second record is a JSON import ledger at ~/.codex/external_agent_session_imports.json. Each entry pairs the Claude transcript it came from with the Codex thread it produced: source_path, content_sha256, imported_thread_id, imported_at. Mine was in there too, correctly linked to the transcript I'd transferred.

So Codex had done everything right and written it all down. The failure was in the reading.

The root cause: four characters, and the one hiding behind them

The plugin's success check lives in scripts/lib/codex.mjs. Here it is as shipped in 1.0.6, unmodified:

function importedThreadIdForSource(sourcePath) {
  const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json");
  if (!fs.existsSync(ledgerPath)) {
    return null;
  }
  const ledger = readJsonFile(ledgerPath);
  const canonicalSource = fs.realpathSync(sourcePath);
  const contentSha256 = sourceContentSha256(canonicalSource);
  const records = Array.isArray(ledger?.records) ? ledger.records : [];
  const match = records
    .filter(
      (record) =>
        record?.source_path === canonicalSource &&
        record?.content_sha256 === contentSha256 &&
        typeof record?.imported_thread_id === "string"
    )
    .at(-1);
  return match?.imported_thread_id ?? null;
}
Enter fullscreen mode Exit fullscreen mode

The caller treats null as a hard error and throws the message I saw.

Now look at the two strings record?.source_path === canonicalSource has to reconcile.

Codex writes the ledger from the Rust side, storing the path in Windows extended-length form:

\\?\C:\Users\<you>\.claude\projects\...\session.jsonl
Enter fullscreen mode Exit fullscreen mode

Node's fs.realpathSync returns the plain form:

C:\Users\<you>\.claude\projects\...\session.jsonl
Enter fullscreen mode Exit fullscreen mode

Those are the same file. They are not the same string. === says no, .filter returns empty, .at(-1) gives undefined, the function returns null, and the plugin reports a failure that never happened. Four characters of prefix.

The \\?\ prefix is the win32 opt-out from MAX_PATH and normal path parsing. Rust's std::fs::canonicalize emits it; Node's realpathSync doesn't. Anything comparing the two needs to normalize first — nothing in codex.mjs does. I grepped the whole file for a normalizer before writing the patch.

Now the part I got wrong at first — the more interesting half.

Look at the filter again: two conditions, not one — the path has to match and content_sha256 has to match. My first read was that because the path never matches on Windows, the hash clause is simply unreachable — dead code, harmless, ignore it.

True about the evaluation. Exactly the wrong conclusion to draw from it. The path bug isn't hiding dead code — it's masking a second live bug, and the mask is the only reason you can't see it.

contentSha256 here is computed by reading the transcript at lookup time. The ledger's content_sha256 was computed by Codex when it imported. Between those two moments, the transcript keeps growing: the transfer is itself a turn in the live session, so Claude Code is still appending to the very file Codex just read. The hash you compute afterwards trails the content that was imported.

So there are two independent reasons the lookup returns null on Windows, and every transfer trips at least the first. On my machine, all 58 ledger records carried the \\?\ prefix, and 12 of 22 surviving source transcripts had already diverged in content from what was recorded. Fix the path and you don't fix the bug — you promote the second failure from theoretical to routine.

I didn't understand that when I wrote my patch. That's why the patch was only half a fix.

The path half is small and platform-honest:

function normalizeLedgerPath(value) {
  if (typeof value !== "string" || value === "") {
    return "";
  }
  let out = value;
  // \\?\UNC\server\share -> \\server\share
  if (out.startsWith("\\\\?\\UNC\\")) {
    out = `\\\\${out.slice(8)}`;
  } else if (out.startsWith("\\\\?\\")) {
    out = out.slice(4);
  }
  out = path.normalize(out);
  return process.platform === "win32" ? out.toLowerCase() : out;
}
Enter fullscreen mode Exit fullscreen mode

And at the comparison, keeping the un-normalized real path for the file read so the hash still works on case-sensitive filesystems:

  const canonicalSource = fs.realpathSync(sourcePath);
  const canonicalKey = normalizeLedgerPath(canonicalSource);
  const contentSha256 = sourceContentSha256(canonicalSource);
  const records = Array.isArray(ledger?.records) ? ledger.records : [];
  const match = records
    .filter(
      (record) =>
        normalizeLedgerPath(record?.source_path) === canonicalKey &&
        record?.content_sha256 === contentSha256 &&
        typeof record?.imported_thread_id === "string"
    )
    .at(-1);
Enter fullscreen mode Exit fullscreen mode

The UNC branch matters because \\?\UNC\server\share is the extended form of \\server\share, so stripping a flat four characters there would produce UNC\server\share — a path that resolves to nothing. Lowercasing is gated on win32 because case-folding paths on Linux is a correctness bug, not a convenience.

The fix that wasn't mine

I submitted the patch above upstream as openai/codex-plugin-cc#551. It didn't land: I deleted the fork a couple of days later, GitHub auto-closed the PR with it, and a PR whose head repo is gone can't be reopened. Two weeks of a maintainer's queue, gone with one housekeeping click.

But the deletion isn't the real problem with #551. It normalized the path and left the hash comparison sitting untouched right next to it. Merged as written, it would have turned a bug that fails every time into one that fails most of the time — harder to report, and arguably worse than what it replaced.

The patch worth watching is #469, and it isn't mine. @ayobamiseun opened it two weeks before mine, and it's the better design for a reason I'd missed entirely: instead of making the two comparisons succeed, it stops depending on them. It snapshots the ledger before the import and falls back to whatever Codex appended during it — sidestepping the path prefix and the hash divergence at once, and defusing trap two below as a bonus.

Worth sitting with for a second: I found the bug, described it correctly, explained a stranger's three-week-old issue — and still wrote the wrong fix, because I'd reasoned carefully about one condition in a two-condition filter. Diagnosis and remedy are different skills; being right about the first doesn't earn you the second.

As of writing, #469 is open, thinly reviewed, and main still compares those paths with ===.

Trap two: a re-transfer that legitimately creates nothing

No longer trusting the plugin's message, I built my own check: snapshot MAX(created_at), run the import, poll for a thread newer than the snapshot. Clean, evidence-based, and wrong in one case.

Transfer the same session twice with nothing new said in between, and Codex creates no new thread. It hashes the transcript content — that's what content_sha256 in the ledger is for — recognizes the re-import, and reuses the existing thread. Correct behavior. My newest-thread-after-snapshot detector polls until it times out and calls that a failure.

So I added a second lookup for the timeout case: find the source path in the ledger (normalizing the prefix, same as above) and reuse the thread it already points at, saying so out loud. Note what that lookup keys on — the path alone, never the hash. That was luck, not foresight, at the time — but it's why my wrapper survives the divergence problem that sank my own upstream patch. Unchanged transcript gives you the same thread; changed transcript gives you a new one. Both are success, and they read differently to the user.

The general shape of this mistake is worth keeping: I had built a detector for state change when the thing I actually wanted to know was state.

Trap three: the app-container PATH trap

This one produced the most misleading error message of the batch.

I had installed the Codex CLI with npm i -g @openai/codex from a shell inside a packaged (MSIX) desktop app. Inside that container, codex resolved fine. Every check I ran passed.

Then the transfer printed a codex resume command, a terminal opened to run it, and:

codex : The term 'codex' is not recognized as the name of a cmdlet,
function, script file, or operable program.
Enter fullscreen mode Exit fullscreen mode

MSIX packages get a virtualized view of the file system. A global npm install from inside the container lands in %LOCALAPPDATA%\Packages\<app>\LocalCache\Roaming\npm\..., and a shell spawned for the user runs outside the container. For that shell, the PATH entry isn't there and, depending on virtualization, neither are the files. The CLI existed and did not exist, depending on who was asking.

The fix is to stop emitting a bare command name that another process has to resolve. My resolver prefers a genuinely global install when there is one, and otherwise embeds an absolute path to the standalone vendored codex.exe in the container's LocalCache backing store — a real directory on disk, readable by any process, with no Node on PATH required.

Trap four: two skill roots, every skill listed twice

Smaller, but it cost me a confused afternoon. Codex discovers skills from both ~/.codex/skills and ~/.agents/skills, the cross-tool Agent Skills root. Put a skill in both — easy to do accidentally, for instance by symlinking one into the other — and it appears twice in the picker. Three copies, three listings.

The useful discovery was the verification command rather than the bug: codex debug prompt-input renders the prompt the model actually sees. Skill list, instructions, all of it. That turns "I think Codex can see my skills" into something checkable. The skill index also caches until the surface restarts, which is its own source of phantom results.

A bonus: the deep-link routes

Once I reliably have a thread id, printing a resume command stops being the best ending. So I went looking for whether Codex could open directly on a thread.

Two routes, both found by inspection rather than documentation. The OpenAI.Codex package manifest declares a codex URL protocol, and the app bundle contains codex://threads/ route strings — so codex://threads/<thread-id> opens the desktop app on that conversation. Separately, the VS Code extension's handleUri forwards the URI path into its webview router, and that router serves local threads at /local/<id> — so vscode://openai.chatgpt/local/<thread-id> opens the panel on it.

Both work. Neither is documented, which is exactly the caveat you should attach to them.

One honest limitation: a URL can't carry a model choice. The -m flag applies to the terminal resume path; the desktop app and the VS Code panel each use their own in-UI model selector.

Where this ended up

The workarounds became a Claude Code plugin: claude-codex-bridge, MIT. It wraps the official plugin's importer rather than replacing it, verifies the outcome against Codex's own state instead of the plugin's message, and deep-links you into the thread.

claude plugin marketplace add AndrewAvery7/claude-codex-bridge
claude plugin install codex-bridge@claude-codex-bridge
Enter fullscreen mode Exit fullscreen mode

Then /to-codex in any session.

The limitations, plainly:

  • Cross-platform, with one honest asterisk. The engine is a single Python file and CI runs its test suite on Windows, macOS and Linux. The complete flow is verified end-to-end on Windows; the macOS and Linux paths (the protocol handler and terminal launch) are implemented and CI-checked but not yet confirmed against a real Codex install on those platforms. Reports either way are welcome.
  • It depends on undocumented Codex internals: the state_5.sqlite threads schema, the import ledger format, and both deep-link routes. A Codex update could move any of them. That is why every transfer also prints the plain codex resume command, which is the fallback that survives.
  • Verified against Codex CLI 0.145, the VS Code extension 26.721, and codex-plugin-cc 1.0.6, on Windows 11 with PowerShell 5.1. Other combinations are untested by me.

The thing I'd take away from this one isn't the prefix. It's that a tool telling you it failed is a claim, not evidence — and when the tool is manipulating state you can read yourself, checking the state is usually cheaper than believing the message.

Top comments (0)