Skip to content

/codex:transfer always fails on Windows: ledger lookup can never match (verbatim \?\ paths + hash of a live transcript) #618

Description

@menzees-source

/codex:transfer always fails on Windows: ledger lookup can never match (verbatim \\?\ paths + hash of a live transcript)

Summary

/codex:transfer reports failure on every attempt on Windows, even when Codex imports the session successfully. The import itself works — a thread is created and recorded — but the plugin's post-import lookup in external_agent_session_imports.json can never match the record, so importExternalAgentSession throws:

Codex reported that the Claude import completed, but did not record an imported thread.
Check the Codex app-server logs for the underlying import error.

There is no app-server error to find: ~/.codex/logs_2.sqlite contains zero import-related rows. The imported thread is sitting in ~/.codex/sessions/... and is fully resumable — the user just never gets told its id.

Two independent defects in importedThreadIdForSource (plugins/codex/scripts/lib/codex.mjs:661) each break the match on their own. A third issue makes the error message uninformative.

Environment

  • Plugin @openai/codex-plugin-cc 1.0.6 (marketplace openai-codex)
  • codex-cli 0.146.0 (desktop install, %LOCALAPPDATA%\Programs\OpenAI\Codex\bin\codex.exe)
  • Windows 11 Home 26200, ARM64, Node v24.18.0
  • Claude Code, CODEX_HOME unset (default C:\Users\<user>\.codex)

Defect 1 — path comparison: Rust verbatim prefix vs Node realpath

Codex writes the ledger source_path via Rust's fs::canonicalize, which on Windows always emits a verbatim extended-length path. The plugin compares it against fs.realpathSync(), which never does:

ledger source_path : "\\\\?\\C:\\Users\\<user>\\.claude\\projects\\C--WINDOWS-system32\\<id>.jsonl"
fs.realpathSync()  : "C:\\Users\\<user>\\.claude\\projects\\C--WINDOWS-system32\\<id>.jsonl"
strictly equal     : false

fs.realpathSync.native() returns the same non-verbatim form, so there is no drop-in Node call that matches. This alone makes record?.source_path === canonicalSource (codex.mjs:673) permanently false on Windows.

Defect 2 — content_sha256 of a still-growing transcript

sourceContentSha256 is computed after the import returns, but the source is the live Claude transcript, which Claude Code appends to continuously (including the turn that invoked /codex:transfer). The hash has already moved on:

content_sha256 in ledger (at import time) : ce85932bed63eace0cd42a9e31c64f9ad57633d839fef6b10ffc836bebad51ad
sha256 recomputed seconds later           : 80d48aa5eb0dfb8a1e7af39c1d58b780e257f1454daeb70123df170bbb14fffc

So record?.content_sha256 === contentSha256 (codex.mjs:674) also fails, on every platform, whenever the session being transferred is the one currently running — i.e. the normal case. Fixing only defect 1 would leave transfers broken.

Defect 3 — failures[] from the completion notification is discarded

requestExternalAgentSessionImport (codex.mjs:701) resolves on externalAgentConfig/import/completed without reading message.params (codex.mjs:714). That payload carries both the authoritative result and any error detail:

{
  "method": "externalAgentConfig/import/completed",
  "params": {
    "importId": "cb789a11-63fe-4787-8520-949aeeb40c44",
    "itemTypeResults": [
      {
        "itemType": "SESSIONS",
        "successes": [
          {
            "itemType": "SESSIONS",
            "cwd": null,
            "source": "\\\\?\\C:\\Users\\<user>\\.claude\\projects\\C--WINDOWS-system32\\<id>.jsonl",
            "target": "019fe103-b7aa-74c1-8fb2-ef6afd58785f"
          }
        ],
        "failures": []
      }
    ]
  }
}

successes[].target is the imported thread id. The ledger round-trip is unnecessary; when it does fail, failures[] is where the real reason would be, and it is dropped.

Related: on the first attempt of the session the ledger file did not exist at all at the moment of the check (no rollout was written either), suggesting the ledger write can lag the completed notification. Reading target from the notification sidesteps that race as well.

Reproduction

On Windows, with Codex ≥ 0.146.0:

  1. In Claude Code, run /codex:transfer.
  2. Observe the "did not record an imported thread" error.
  3. Observe that ~/.codex/external_agent_session_imports.json gained a record with a valid imported_thread_id, and ~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*-<thread-id>.jsonl exists and holds the imported turns.
  4. codex resume <imported_thread_id> works fine.

Ran the command three times: three successful imports, three reported failures.

Suggested fix

Prefer the completion notification, keep the ledger as a fallback, and relax both comparisons in the fallback.

  1. Return the notification payload:
   client.setNotificationHandler((message) => {
     if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
-      resolveCompleted();
+      resolveCompleted(message.params ?? null);
       return;
     }
     previousHandler?.(message);
   });
   ...
   try {
     await client.request("externalAgentConfig/import", params);
-    await completed;
+    return await completed;
   } finally {
  1. Resolve the thread id from successes[].target first, and surface failures[] on error:
-    const threadId = importedThreadIdForSource(options.sourcePath);
+    const threadId =
+      importedThreadIdFromCompletion(completion, options.sourcePath) ??
+      importedThreadIdForSource(options.sourcePath);
     if (!threadId) {
-      const stderr = cleanCodexStderr(client.stderr);
-      throw new Error(
-        `Codex reported that the Claude import completed, but did not record an imported thread.${stderr ? `\n${stderr}` : " Check the Codex app-server logs for the underlying import error."}`
-      );
+      const details = [importFailureDetails(completion), cleanCodexStderr(client.stderr)].filter(Boolean);
+      throw new Error(
+        [
+          "Codex reported that the Claude import completed, but did not record an imported thread.",
+          ...(details.length ? details : ["Check the Codex app-server logs for the underlying import error."])
+        ].join("\n")
+      );
     }
  1. Normalize paths before comparing, and demote content_sha256 from a requirement to a preference:
function normalizeImportPath(value) {
  if (typeof value !== "string" || value === "") {
    return null;
  }
  // Codex canonicalizes with Rust's fs::canonicalize, which emits Windows
  // verbatim paths (\\?\C:\...); Node's realpathSync never does.
  const stripped = value.replace(/^\\\\\?\\UNC\\/, "\\\\").replace(/^\\\\\?\\/, "");
  const normalized = path.normalize(stripped);
  return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}

function importedThreadIdForSource(sourcePath) {
  // ...
  const candidates = records.filter(
    (record) =>
      typeof record?.imported_thread_id === "string" &&
      normalizeImportPath(record?.source_path) === normalizeImportPath(realSource)
  );
  // A live Claude transcript keeps growing while the import runs, so its hash
  // usually no longer matches the imported snapshot.
  return (
    candidates.filter((r) => r?.content_sha256 === contentSha256).at(-1) ?? candidates.at(-1)
  )?.imported_thread_id ?? null;
}

Alternatively, have Codex write a non-verbatim source_path (dunce-style de-canonicalization) and record the hash the plugin can reproduce — but the notification-first approach makes the plugin robust regardless of which side changes.

Verification

Applied the above locally to plugins/codex/scripts/lib/codex.mjs:

Transferred the Claude session into a Codex thread with visible turn history.
Codex session ID: 019fe107-6264-7260-8f15-b033ed23c603
Resume in Codex: codex resume 019fe107-6264-7260-8f15-b033ed23c603

The ledger fallback was exercised separately against the real external_agent_session_imports.json: 3/3 records match after normalization and resolve to the same thread id, so the fallback still works if a future Codex version omits target.

Happy to open a PR if the notification-first direction looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions