From 6a2ce8db7f727405c01197b11c5694458d3a60e7 Mon Sep 17 00:00:00 2001 From: Ehsan Hesam <121943503+ehsan-parloa@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:58:30 +0100 Subject: [PATCH 1/5] fix(transfer): report already-imported sessions and add --refresh Codex's externalAgentConfig/import skips a Claude transcript whose path is already in its import ledger, even after the transcript has grown, and completes with no successes and no failures. The plugin then looked for a ledger record matching the transcript's current content hash, found none, and failed with "did not record an imported thread". - Take the imported thread id from the import/completed notification (successes[].target) and surface failures[] messages. - When Codex skips a path it already imported, print the existing thread and say whether later turns are missing from it, instead of failing. - Add --refresh: back up the ledger, drop this transcript's records and import the current content into a new thread. - For Codex versions that complete without structured results, accept the ledger record added by this import by path, so a transcript that grows during the import no longer breaks the lookup. - Make the fake Codex skip already-imported paths and report structured results, as Codex 0.166 does. Fixes #750 Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + plugins/codex/commands/transfer.md | 2 +- plugins/codex/scripts/codex-companion.mjs | 33 +++++- plugins/codex/scripts/lib/codex.mjs | 134 +++++++++++++++++----- tests/fake-codex-fixture.mjs | 47 ++++++-- tests/runtime.test.mjs | 111 ++++++++++++++++++ 6 files changed, 285 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 937a3037b..59407b1b3 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,13 @@ Examples: ```bash /codex:transfer /codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl +/codex:transfer --refresh ``` The plugin's existing `SessionStart` hook supplies the current transcript path automatically; `--source` is available as a manual override. The transfer uses Codex's external-agent session importer, so it follows the same conversion rules as importing Claude history in the Codex App and creates visible turns that can be continued in the App or TUI. The source must be under `~/.claude/projects`, and older Codex versions that do not expose session import must be upgraded before using this command. +Codex imports each Claude transcript only once. If the session was already transferred (by this command or by importing Claude history in the Codex App), `/codex:transfer` prints the existing thread instead and says whether later turns are missing from it. `--refresh` removes the earlier import record from `$CODEX_HOME/external_agent_session_imports.json` (a backup is written next to it) and imports the current transcript into a new Codex thread; the earlier thread is left unchanged. + ### `/codex:status` Shows running and recent Codex jobs for the current repository. diff --git a/plugins/codex/commands/transfer.md b/plugins/codex/commands/transfer.md index 42170e51d..974ccade2 100644 --- a/plugins/codex/commands/transfer.md +++ b/plugins/codex/commands/transfer.md @@ -1,6 +1,6 @@ --- description: Transfer the current Claude Code session into a resumable Codex thread -argument-hint: "[--source ]" +argument-hint: "[--source ] [--refresh]" disable-model-invocation: true allowed-tools: Bash(node:*) --- diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..d0b3ab252 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -80,7 +80,7 @@ function printUsage() { " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", - " node scripts/codex-companion.mjs transfer [--source ] [--json]", + " node scripts/codex-companion.mjs transfer [--source ] [--refresh] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -614,11 +614,28 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId } function renderTransferResult(payload) { + if (payload.alreadyImported) { + const when = payload.importedAt ? ` on ${payload.importedAt}` : ""; + const lines = [ + payload.upToDate + ? `This Claude session was already transferred to Codex${when}, and the transcript has not changed since.` + : `This Claude session was already transferred to Codex${when}. Codex imports a transcript only once, so that thread does not include later turns.`, + `Codex session ID: ${payload.threadId}`, + `Resume in Codex: ${payload.resumeCommand}` + ]; + if (!payload.upToDate) { + lines.push("To import the current session into a new Codex thread, run `/codex:transfer --refresh`."); + } + return `${lines.join("\n")}\n`; + } const lines = [ "Transferred the Claude session into a Codex thread with visible turn history.", `Codex session ID: ${payload.threadId}`, `Resume in Codex: ${payload.resumeCommand}` ]; + if (payload.refreshed) { + lines.push(`Replaced the earlier import record for this session; the earlier Codex thread is unchanged. Ledger backup: ${payload.ledgerBackupPath}`); + } return `${lines.join("\n")}\n`; } @@ -626,12 +643,17 @@ async function executeTransfer(cwd, options = {}) { const sourcePath = resolveClaudeSessionPath(cwd, { source: options.source }); - const result = await importExternalAgentSession(cwd, { sourcePath }); + const result = await importExternalAgentSession(cwd, { sourcePath, refresh: Boolean(options.refresh) }); const payload = { threadId: result.threadId, resumeCommand: `codex resume ${result.threadId}`, sourcePath, - sessionId: path.basename(sourcePath, ".jsonl") + sessionId: path.basename(sourcePath, ".jsonl"), + alreadyImported: result.alreadyImported, + upToDate: result.upToDate, + importedAt: result.importedAt, + refreshed: result.refreshed, + ledgerBackupPath: result.ledgerBackupPath }; return { @@ -825,12 +847,13 @@ async function handleTask(argv) { async function handleTransfer(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd", "source"], - booleanOptions: ["json"] + booleanOptions: ["json", "refresh"] }); const cwd = resolveCommandCwd(options); const { payload, rendered } = await executeTransfer(cwd, { - source: options.source + source: options.source, + refresh: options.refresh }); outputCommandResult(payload, rendered, options.json); } diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..9df1f4910 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -658,24 +658,56 @@ function sourceContentSha256(sourcePath) { return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); } -function importedThreadIdForSource(sourcePath) { - const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json"); - if (!fs.existsSync(ledgerPath)) { +function importLedgerPath() { + return path.join(resolveCodexHome(), "external_agent_session_imports.json"); +} + +function readImportLedger() { + const ledgerPath = importLedgerPath(); + return fs.existsSync(ledgerPath) ? readJsonFile(ledgerPath) : null; +} + +function importRecordsForSource(canonicalSource) { + const ledger = readImportLedger(); + const records = Array.isArray(ledger?.records) ? ledger.records : []; + return records.filter( + (record) => record?.source_path === canonicalSource && typeof record?.imported_thread_id === "string" + ); +} + +// Codex records each imported transcript by path and skips a path it has already imported, +// even after the transcript has grown. `--refresh` removes this transcript's records (after +// backing up the ledger) so that Codex imports the current content into a new thread. +function forgetImportRecordsForSource(canonicalSource) { + const ledger = readImportLedger(); + const records = Array.isArray(ledger?.records) ? ledger.records : []; + const kept = records.filter((record) => record?.source_path !== canonicalSource); + if (kept.length === records.length) { + return { removed: 0, backupPath: null }; + } + const ledgerPath = importLedgerPath(); + const backupPath = `${ledgerPath}.bak-${Date.now()}`; + fs.copyFileSync(ledgerPath, backupPath); + const tempPath = `${ledgerPath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: kept }, null, 2)}\n`); + fs.renameSync(tempPath, ledgerPath); + return { removed: records.length - kept.length, backupPath }; +} + +function sessionImportResults(completion) { + if (!Array.isArray(completion?.itemTypeResults)) { 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; + const sessions = completion.itemTypeResults.filter((result) => result?.itemType === "SESSIONS"); + return { + successes: sessions.flatMap((result) => (Array.isArray(result.successes) ? result.successes : [])), + failures: sessions.flatMap((result) => (Array.isArray(result.failures) ? result.failures : [])) + }; +} + +function importedAtIso(record) { + const seconds = Number(record?.imported_at); + return Number.isFinite(seconds) && seconds > 0 ? new Date(seconds * 1000).toISOString() : null; } function externalAgentSessionMigration(sourcePath, cwd) { @@ -711,7 +743,7 @@ async function requestExternalAgentSessionImport(client, params) { client.setNotificationHandler((message) => { if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) { - resolveCompleted(); + resolveCompleted(message.params ?? null); return; } previousHandler?.(message); @@ -722,7 +754,7 @@ async function requestExternalAgentSessionImport(client, params) { try { await client.request("externalAgentConfig/import", params); - await completed; + return await completed; } finally { clearTimeout(timeout); client.setNotificationHandler(previousHandler ?? null); @@ -1064,10 +1096,18 @@ export async function importExternalAgentSession(cwd, options = {}) { throw new Error("A Claude session source path is required."); } + const canonicalSource = fs.realpathSync(options.sourcePath); return withDirectAppServer(cwd, async (client) => { + const priorRecords = importRecordsForSource(canonicalSource); + const priorThreadIds = new Set(priorRecords.map((record) => record.imported_thread_id)); + const refresh = options.refresh ? forgetImportRecordsForSource(canonicalSource) : { removed: 0, backupPath: null }; + if (refresh.removed > 0) { + emitProgress(options.onProgress, `Removed the earlier import record (ledger backup: ${refresh.backupPath}).`, "transferring"); + } emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); + let completion; try { - await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); + completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); } catch (error) { if (error?.rpcCode === -32601) { throw new Error( @@ -1077,18 +1117,52 @@ export async function importExternalAgentSession(cwd, options = {}) { } throw error; } - const threadId = 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 stderr = cleanCodexStderr(client.stderr); + const results = sessionImportResults(completion); + const imported = results?.successes.find((success) => typeof success?.target === "string" && success.target); + // Codex versions that do not report results: use the ledger record this import added. Match + // on path only, because the live transcript can grow after Codex hashed it. + const created = imported + ? null + : importRecordsForSource(canonicalSource) + .filter((record) => !priorThreadIds.has(record.imported_thread_id)) + .at(-1); + const threadId = imported?.target ?? created?.imported_thread_id ?? null; + if (threadId) { + emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); + return { + threadId, + alreadyImported: false, + upToDate: null, + importedAt: null, + refreshed: refresh.removed > 0, + ledgerBackupPath: refresh.backupPath, + stderr + }; } - emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); - return { - threadId, - stderr: cleanCodexStderr(client.stderr) - }; + if (results?.failures.length) { + const detail = results.failures.map((failure) => failure?.message).filter(Boolean).join("; "); + throw new Error(`Codex could not import the Claude session${detail ? `: ${detail}` : "."}${stderr ? `\n${stderr}` : ""}`); + } + // Codex skipped the transcript because it already imported this path. + const previous = options.refresh ? null : priorRecords.at(-1); + if (previous) { + emitProgress(options.onProgress, `Claude session already imported (${previous.imported_thread_id}).`, "completed", { + threadId: previous.imported_thread_id + }); + return { + threadId: previous.imported_thread_id, + alreadyImported: true, + upToDate: previous.content_sha256 === sourceContentSha256(canonicalSource), + importedAt: importedAtIso(previous), + refreshed: false, + ledgerBackupPath: null, + 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."}` + ); }); } diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..ec04aebcf 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -368,16 +368,34 @@ rl.on("line", (line) => { throw new Error("missing external session migration"); } const sourcePath = fs.realpathSync(session.path); + const sessionResults = (successes, failures) => ({ + importId: "import_" + message.id, + itemTypeResults: [{ itemType: "SESSIONS", successes, failures }] + }); + if (BEHAVIOR === "external-import-reports-failure") { + send({ id: message.id, result: { importId: "import_" + message.id } }); + send({ + method: "externalAgentConfig/import/completed", + params: sessionResults([], [{ + itemType: "SESSIONS", + errorType: null, + subErrorType: null, + failureStage: "import", + message: "unsupported transcript record", + cwd: session.cwd, + source: sourcePath + }]) + }); + break; + } const contents = fs.readFileSync(sourcePath, "utf8"); const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex"); const ledger = loadImportLedger(); - let record = ledger.records.find( - (candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256 - ); - let thread; - if (record) { - thread = ensureThread(state, record.imported_thread_id); - } else { + // Like Codex 0.166: a source path that is already in the ledger is skipped, even when + // its content changed, and the completion reports neither a success nor a failure. + let record = ledger.records.find((candidate) => candidate.source_path === sourcePath); + let thread = null; + if (!record) { const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line)); const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null; const messages = records @@ -399,8 +417,19 @@ rl.on("line", (line) => { saveState(state); saveImportLedger(ledger); } - send({ id: message.id, result: {} }); - send({ method: "externalAgentConfig/import/completed", params: {} }); + if (BEHAVIOR === "external-import-legacy-live") { + // Older Codex versions complete without structured results, and the live Claude + // transcript keeps growing after Codex has hashed it. + fs.appendFileSync(sourcePath, JSON.stringify({ type: "user", message: { role: "user", content: "late turn" } }) + "\\n"); + send({ id: message.id, result: {} }); + send({ method: "externalAgentConfig/import/completed", params: {} }); + break; + } + const successes = thread + ? [{ itemType: "SESSIONS", cwd: session.cwd, source: sourcePath, target: thread.id, title: thread.name }] + : []; + send({ id: message.id, result: { importId: "import_" + message.id } }); + send({ method: "externalAgentConfig/import/completed", params: sessionResults(successes, []) }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..77ba479fd 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -303,6 +303,117 @@ test("transfer fails visibly when native import completes without a ledger recor assert.match(result.stderr, /did not record an imported thread/); }); +function setupTransferFixture(behavior) { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const sourcePath = path.join(projectDir, "session.jsonl"); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir, behavior); + initGitRepo(repo); + fs.writeFileSync( + sourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "First request." } })}\n`, + "utf8" + ); + const codexHome = path.join(home, ".codex"); + const env = { + ...buildEnv(binDir), + HOME: home, + CODEX_HOME: codexHome, + CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath + }; + // Commands receive "$ARGUMENTS" as a single argv element, so pass options the same way. + const transfer = (rawArguments) => run("node", [SCRIPT, "transfer", rawArguments], { cwd: repo, env }); + const appendTurn = (content) => + fs.appendFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content } })}\n`); + const ledgerRecords = () => + JSON.parse(fs.readFileSync(path.join(codexHome, "external_agent_session_imports.json"), "utf8")).records; + const fakeThreads = () => JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")).threads; + return { codexHome, sourcePath, transfer, appendTurn, ledgerRecords, fakeThreads }; +} + +test("transfer reports an already-imported session instead of failing", () => { + const fixture = setupTransferFixture(); + + const first = fixture.transfer("--json"); + assert.equal(first.status, 0, first.stderr); + assert.equal(JSON.parse(first.stdout).threadId, "thr_1"); + + const unchanged = fixture.transfer("--json"); + assert.equal(unchanged.status, 0, unchanged.stderr); + const unchangedPayload = JSON.parse(unchanged.stdout); + assert.equal(unchangedPayload.threadId, "thr_1"); + assert.equal(unchangedPayload.alreadyImported, true); + assert.equal(unchangedPayload.upToDate, true); + + fixture.appendTurn("More work after the first transfer."); + const grown = fixture.transfer("--json"); + assert.equal(grown.status, 0, grown.stderr); + const grownPayload = JSON.parse(grown.stdout); + assert.equal(grownPayload.threadId, "thr_1"); + assert.equal(grownPayload.alreadyImported, true); + assert.equal(grownPayload.upToDate, false); + assert.match(grownPayload.importedAt, /^\d{4}-\d{2}-\d{2}T/); + + const rendered = fixture.transfer(""); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /already transferred to Codex/); + assert.match(rendered.stdout, /codex resume thr_1/); + assert.match(rendered.stdout, /\/codex:transfer --refresh/); + assert.equal(fixture.fakeThreads().length, 1); +}); + +test("transfer --refresh re-imports a grown transcript into a new thread", () => { + const fixture = setupTransferFixture(); + assert.equal(fixture.transfer("").status, 0); + fixture.appendTurn("More work after the first transfer."); + + const refreshed = fixture.transfer(`--refresh --source ${fixture.sourcePath} --json`); + assert.equal(refreshed.status, 0, refreshed.stderr); + const payload = JSON.parse(refreshed.stdout); + assert.equal(payload.threadId, "thr_2"); + assert.equal(payload.alreadyImported, false); + assert.equal(payload.refreshed, true); + assert.ok(fs.existsSync(payload.ledgerBackupPath), "the previous ledger is backed up"); + assert.equal(path.dirname(payload.ledgerBackupPath), fixture.codexHome); + + const records = fixture.ledgerRecords(); + assert.equal(records.length, 1); + assert.equal(records[0].imported_thread_id, "thr_2"); + assert.equal(fixture.fakeThreads().length, 2); + assert.deepEqual( + fixture.fakeThreads().find((thread) => thread.id === "thr_2").visibleMessages.map((message) => message.text), + ["First request.", "More work after the first transfer."] + ); + + const rendered = fixture.transfer(""); + assert.match(rendered.stdout, /already transferred to Codex/); + assert.match(rendered.stdout, /codex resume thr_2/); +}); + +test("transfer surfaces import failures reported by Codex", () => { + const fixture = setupTransferFixture("external-import-reports-failure"); + + const result = fixture.transfer(""); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Codex could not import the Claude session: unsupported transcript record/); +}); + +test("transfer resolves the imported thread when Codex omits results and the transcript keeps growing", () => { + const fixture = setupTransferFixture("external-import-legacy-live"); + + const result = fixture.transfer("--json"); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.threadId, "thr_1"); + assert.equal(payload.alreadyImported, false); +}); + test("transfer rejects sources outside the Claude projects directory", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); From dad0ba478c932c8341fc6d9043145aa25237819e Mon Sep 17 00:00:00 2001 From: Ehsan Hesam <121943503+ehsan-parloa@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:25:13 +0100 Subject: [PATCH 2/5] fix(transfer): keep --source in the --refresh hint When an already-imported transcript was selected with --source, the printed hint dropped the override, so following it refreshed the session's own transcript instead. The hint now includes --source with the resolved path, and --json output exposes it as refreshCommand. Co-Authored-By: Claude Opus 5.5 --- plugins/codex/scripts/codex-companion.mjs | 11 +++++++++-- tests/runtime.test.mjs | 22 ++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index d0b3ab252..dcf8eff35 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -623,8 +623,8 @@ function renderTransferResult(payload) { `Codex session ID: ${payload.threadId}`, `Resume in Codex: ${payload.resumeCommand}` ]; - if (!payload.upToDate) { - lines.push("To import the current session into a new Codex thread, run `/codex:transfer --refresh`."); + if (payload.refreshCommand) { + lines.push(`To import the current session into a new Codex thread, run \`${payload.refreshCommand}\`.`); } return `${lines.join("\n")}\n`; } @@ -644,6 +644,12 @@ async function executeTransfer(cwd, options = {}) { source: options.source }); const result = await importExternalAgentSession(cwd, { sourcePath, refresh: Boolean(options.refresh) }); + // Keep an explicit --source in the hint; without it, --refresh would resolve the session's own transcript. + const sourceArgument = /[\s"'\\]/.test(sourcePath) ? JSON.stringify(sourcePath) : sourcePath; + const refreshCommand = + result.alreadyImported && !result.upToDate + ? `/codex:transfer --refresh${options.source ? ` --source ${sourceArgument}` : ""}` + : null; const payload = { threadId: result.threadId, resumeCommand: `codex resume ${result.threadId}`, @@ -652,6 +658,7 @@ async function executeTransfer(cwd, options = {}) { alreadyImported: result.alreadyImported, upToDate: result.upToDate, importedAt: result.importedAt, + refreshCommand, refreshed: result.refreshed, ledgerBackupPath: result.ledgerBackupPath }; diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 77ba479fd..8b7da914b 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -394,6 +394,28 @@ test("transfer --refresh re-imports a grown transcript into a new thread", () => assert.match(rendered.stdout, /codex resume thr_2/); }); +test("transfer keeps an explicit --source in the refresh hint", () => { + const fixture = setupTransferFixture(); + const olderSource = path.join(path.dirname(fixture.sourcePath), "older-session.jsonl"); + fs.writeFileSync(olderSource, `${JSON.stringify({ type: "user", message: { role: "user", content: "Older request." } })}\n`); + assert.equal(fixture.transfer("").status, 0); + assert.equal(fixture.transfer(`--source ${olderSource}`).status, 0); + fs.appendFileSync(olderSource, `${JSON.stringify({ type: "user", message: { role: "user", content: "Older follow-up." } })}\n`); + + const result = fixture.transfer(`--source ${olderSource} --json`); + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.alreadyImported, true); + assert.equal(payload.refreshCommand, `/codex:transfer --refresh --source ${fs.realpathSync(olderSource)}`); + + // Following the hint refreshes the older transcript, not the session's own transcript. + const refreshed = fixture.transfer(`${payload.refreshCommand.replace("/codex:transfer ", "")} --json`); + assert.equal(refreshed.status, 0, refreshed.stderr); + const records = fixture.ledgerRecords(); + assert.equal(records.find((record) => record.source_path === fs.realpathSync(fixture.sourcePath)).imported_thread_id, "thr_1"); + assert.equal(records.find((record) => record.source_path === fs.realpathSync(olderSource)).imported_thread_id, "thr_3"); +}); + test("transfer surfaces import failures reported by Codex", () => { const fixture = setupTransferFixture("external-import-reports-failure"); From 7e4a12db3a39fac6e20ab0c026ea94a5a9935553 Mon Sep 17 00:00:00 2001 From: Ehsan Hesam <121943503+ehsan-parloa@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:25:15 +0100 Subject: [PATCH 3/5] fix(transfer): keep concurrent ledger updates during --refresh Codex writes external_agent_session_imports.json without a lock the plugin could share, so a refresh could overwrite a record that another import added between reading and replacing the ledger. Re-read the ledger right before the rename and start over when it changed, and write the backup from the exact content being replaced. Co-Authored-By: Claude Opus 5.5 --- plugins/codex/scripts/lib/codex.mjs | 41 +++++++++++----- tests/transfer-ledger.test.mjs | 73 +++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 tests/transfer-ledger.test.mjs diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 9df1f4910..75a0c08bc 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -49,6 +49,7 @@ const TASK_THREAD_PREFIX = "Codex Companion Task"; const DEFAULT_CONTINUE_PROMPT = "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."; const EXTERNAL_AGENT_IMPORT_COMPLETED = "externalAgentConfig/import/completed"; +const LEDGER_REWRITE_ATTEMPTS = 5; const EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000; function cleanCodexStderr(stderr) { @@ -678,20 +679,34 @@ function importRecordsForSource(canonicalSource) { // Codex records each imported transcript by path and skips a path it has already imported, // even after the transcript has grown. `--refresh` removes this transcript's records (after // backing up the ledger) so that Codex imports the current content into a new thread. -function forgetImportRecordsForSource(canonicalSource) { - const ledger = readImportLedger(); - const records = Array.isArray(ledger?.records) ? ledger.records : []; - const kept = records.filter((record) => record?.source_path !== canonicalSource); - if (kept.length === records.length) { - return { removed: 0, backupPath: null }; - } +// +// Codex writes the ledger without a lock the plugin could share, so the rewrite is optimistic: +// it re-reads the ledger right before the rename and starts over if another import changed it. +export function forgetImportRecordsForSource(canonicalSource) { const ledgerPath = importLedgerPath(); - const backupPath = `${ledgerPath}.bak-${Date.now()}`; - fs.copyFileSync(ledgerPath, backupPath); - const tempPath = `${ledgerPath}.tmp-${process.pid}`; - fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: kept }, null, 2)}\n`); - fs.renameSync(tempPath, ledgerPath); - return { removed: records.length - kept.length, backupPath }; + for (let attempt = 0; attempt < LEDGER_REWRITE_ATTEMPTS; attempt += 1) { + if (!fs.existsSync(ledgerPath)) { + return { removed: 0, backupPath: null }; + } + const original = fs.readFileSync(ledgerPath, "utf8"); + const ledger = JSON.parse(original); + const records = Array.isArray(ledger?.records) ? ledger.records : []; + const kept = records.filter((record) => record?.source_path !== canonicalSource); + if (kept.length === records.length) { + return { removed: 0, backupPath: null }; + } + const tempPath = `${ledgerPath}.tmp-${process.pid}`; + fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: kept }, null, 2)}\n`); + if (fs.readFileSync(ledgerPath, "utf8") !== original) { + fs.rmSync(tempPath, { force: true }); + continue; + } + const backupPath = `${ledgerPath}.bak-${Date.now()}`; + fs.writeFileSync(backupPath, original); + fs.renameSync(tempPath, ledgerPath); + return { removed: records.length - kept.length, backupPath }; + } + throw new Error("Codex's import ledger kept changing while `--refresh` tried to update it. Retry in a moment."); } function sessionImportResults(completion) { diff --git a/tests/transfer-ledger.test.mjs b/tests/transfer-ledger.test.mjs new file mode 100644 index 000000000..da536599a --- /dev/null +++ b/tests/transfer-ledger.test.mjs @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { makeTempDir } from "./helpers.mjs"; +import { forgetImportRecordsForSource } from "../plugins/codex/scripts/lib/codex.mjs"; + +function importRecord(sourcePath, threadId) { + return { + source_path: sourcePath, + content_sha256: `sha-${threadId}`, + imported_thread_id: threadId, + imported_at: 1, + source_modified_at: null + }; +} + +test("--refresh keeps ledger records that another import adds while it rewrites the ledger", (t) => { + const codexHome = makeTempDir(); + const ledgerPath = path.join(codexHome, "external_agent_session_imports.json"); + fs.writeFileSync( + ledgerPath, + JSON.stringify( + { records: [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")], detected_connector_records: [] }, + null, + 2 + ) + ); + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = codexHome; + t.after(() => { + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + }); + + // Codex imports for another session write the ledger without any lock. Land one of those + // writes while the refresh is preparing its replacement file. + const writeFileSync = fs.writeFileSync; + let interleaved = false; + t.mock.method(fs, "writeFileSync", (file, ...rest) => { + if (!interleaved && String(file).startsWith(`${ledgerPath}.tmp-`)) { + interleaved = true; + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + ledger.records.push(importRecord("/c.jsonl", "thr_c")); + writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); + } + return writeFileSync(file, ...rest); + }); + + const result = forgetImportRecordsForSource("/a.jsonl"); + + assert.equal(interleaved, true); + assert.equal(result.removed, 1); + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + assert.deepEqual( + ledger.records.map((record) => record.imported_thread_id), + ["thr_b", "thr_c"] + ); + assert.deepEqual(ledger.detected_connector_records, []); + const backup = JSON.parse(fs.readFileSync(result.backupPath, "utf8")); + assert.deepEqual( + backup.records.map((record) => record.imported_thread_id), + ["thr_a", "thr_b", "thr_c"] + ); + assert.deepEqual( + fs.readdirSync(codexHome).filter((name) => name.includes(".tmp-")), + [] + ); +}); From a035a5af98597556a66521a8dc80cceaf23f6533 Mon Sep 17 00:00:00 2001 From: Ehsan Hesam <121943503+ehsan-parloa@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:32:00 +0100 Subject: [PATCH 4/5] fix(transfer): restore the import record when --refresh fails --refresh removed the transcript's ledger records before asking Codex to import it. When the import was then rejected (an RPC error, a reported failure, or no record written), the records stayed removed and the next transfer lost the mapping to the original thread. Put the removed records back on any failed refresh, unless Codex recorded the transcript again, keep other ledger updates, and name the ledger backup in the error. Co-Authored-By: Claude Opus 5.5 --- plugins/codex/scripts/lib/codex.mjs | 197 +++++++++++++++++----------- tests/runtime.test.mjs | 27 +++- tests/transfer-ledger.test.mjs | 42 ++++-- 3 files changed, 182 insertions(+), 84 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 75a0c08bc..54725af73 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -676,37 +676,81 @@ function importRecordsForSource(canonicalSource) { ); } -// Codex records each imported transcript by path and skips a path it has already imported, -// even after the transcript has grown. `--refresh` removes this transcript's records (after -// backing up the ledger) so that Codex imports the current content into a new thread. -// -// Codex writes the ledger without a lock the plugin could share, so the rewrite is optimistic: -// it re-reads the ledger right before the rename and starts over if another import changed it. -export function forgetImportRecordsForSource(canonicalSource) { +// Codex writes the ledger without a lock the plugin could share, so rewrites are optimistic: +// re-read the ledger right before the rename and start over if another import changed it. +// `update` returns the new records, or null to leave the ledger as it is. +function rewriteImportLedger(update, { backup = false } = {}) { const ledgerPath = importLedgerPath(); for (let attempt = 0; attempt < LEDGER_REWRITE_ATTEMPTS; attempt += 1) { if (!fs.existsSync(ledgerPath)) { - return { removed: 0, backupPath: null }; + return null; } const original = fs.readFileSync(ledgerPath, "utf8"); const ledger = JSON.parse(original); const records = Array.isArray(ledger?.records) ? ledger.records : []; - const kept = records.filter((record) => record?.source_path !== canonicalSource); - if (kept.length === records.length) { - return { removed: 0, backupPath: null }; + const nextRecords = update(records); + if (!nextRecords) { + return null; } const tempPath = `${ledgerPath}.tmp-${process.pid}`; - fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: kept }, null, 2)}\n`); + fs.writeFileSync(tempPath, `${JSON.stringify({ ...ledger, records: nextRecords }, null, 2)}\n`); if (fs.readFileSync(ledgerPath, "utf8") !== original) { fs.rmSync(tempPath, { force: true }); continue; } - const backupPath = `${ledgerPath}.bak-${Date.now()}`; - fs.writeFileSync(backupPath, original); + const backupPath = backup ? `${ledgerPath}.bak-${Date.now()}` : null; + if (backupPath) { + fs.writeFileSync(backupPath, original); + } fs.renameSync(tempPath, ledgerPath); - return { removed: records.length - kept.length, backupPath }; + return { records, backupPath }; + } + throw new Error("Codex's import ledger kept changing while the plugin tried to update it. Retry in a moment."); +} + +// Codex records each imported transcript by path and skips a path it has already imported, +// even after the transcript has grown. `--refresh` removes this transcript's records (after +// backing up the ledger) so that Codex imports the current content into a new thread. +export function forgetImportRecordsForSource(canonicalSource) { + const result = rewriteImportLedger( + (records) => { + const kept = records.filter((record) => record?.source_path !== canonicalSource); + return kept.length === records.length ? null : kept; + }, + { backup: true } + ); + if (!result) { + return { removed: 0, removedRecords: [], backupPath: null }; + } + const removedRecords = result.records.filter((record) => record?.source_path === canonicalSource); + return { removed: removedRecords.length, removedRecords, backupPath: result.backupPath }; +} + +// Puts back the records a failed `--refresh` removed, unless Codex has recorded this source again. +export function restoreImportRecordsForSource(canonicalSource, removedRecords) { + if (!removedRecords?.length) { + return false; + } + const result = rewriteImportLedger((records) => + records.some((record) => record?.source_path === canonicalSource) ? null : [...records, ...removedRecords] + ); + return Boolean(result); +} + +function withRefreshRestored(error, canonicalSource, refresh) { + if (!refresh.removed) { + return error; + } + let note; + try { + note = restoreImportRecordsForSource(canonicalSource, refresh.removedRecords) + ? `The earlier import record for this transcript was restored (ledger backup: ${refresh.backupPath}).` + : `Codex recorded this transcript again, so the earlier import record was not restored (ledger backup: ${refresh.backupPath}).`; + } catch { + note = `The earlier import record for this transcript could not be restored. The ledger backup is at ${refresh.backupPath}.`; } - throw new Error("Codex's import ledger kept changing while `--refresh` tried to update it. Retry in a moment."); + const message = error instanceof Error ? error.message : String(error); + return new Error(`${message}\n${note}`, { cause: error }); } function sessionImportResults(completion) { @@ -1115,69 +1159,76 @@ export async function importExternalAgentSession(cwd, options = {}) { return withDirectAppServer(cwd, async (client) => { const priorRecords = importRecordsForSource(canonicalSource); const priorThreadIds = new Set(priorRecords.map((record) => record.imported_thread_id)); - const refresh = options.refresh ? forgetImportRecordsForSource(canonicalSource) : { removed: 0, backupPath: null }; + const refresh = options.refresh + ? forgetImportRecordsForSource(canonicalSource) + : { removed: 0, removedRecords: [], backupPath: null }; if (refresh.removed > 0) { emitProgress(options.onProgress, `Removed the earlier import record (ledger backup: ${refresh.backupPath}).`, "transferring"); } - emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); - let completion; try { - completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); - } catch (error) { - if (error?.rpcCode === -32601) { - throw new Error( - "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.", - { cause: error } - ); + emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); + let completion; + try { + completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); + } catch (error) { + if (error?.rpcCode === -32601) { + throw new Error( + "This Codex version does not support Claude session transfer. Update Codex with `npm install -g @openai/codex@latest`, then retry.", + { cause: error } + ); + } + throw error; } - throw error; - } - const stderr = cleanCodexStderr(client.stderr); - const results = sessionImportResults(completion); - const imported = results?.successes.find((success) => typeof success?.target === "string" && success.target); - // Codex versions that do not report results: use the ledger record this import added. Match - // on path only, because the live transcript can grow after Codex hashed it. - const created = imported - ? null - : importRecordsForSource(canonicalSource) - .filter((record) => !priorThreadIds.has(record.imported_thread_id)) - .at(-1); - const threadId = imported?.target ?? created?.imported_thread_id ?? null; - if (threadId) { - emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); - return { - threadId, - alreadyImported: false, - upToDate: null, - importedAt: null, - refreshed: refresh.removed > 0, - ledgerBackupPath: refresh.backupPath, - stderr - }; - } - if (results?.failures.length) { - const detail = results.failures.map((failure) => failure?.message).filter(Boolean).join("; "); - throw new Error(`Codex could not import the Claude session${detail ? `: ${detail}` : "."}${stderr ? `\n${stderr}` : ""}`); - } - // Codex skipped the transcript because it already imported this path. - const previous = options.refresh ? null : priorRecords.at(-1); - if (previous) { - emitProgress(options.onProgress, `Claude session already imported (${previous.imported_thread_id}).`, "completed", { - threadId: previous.imported_thread_id - }); - return { - threadId: previous.imported_thread_id, - alreadyImported: true, - upToDate: previous.content_sha256 === sourceContentSha256(canonicalSource), - importedAt: importedAtIso(previous), - refreshed: false, - ledgerBackupPath: null, - stderr - }; + const stderr = cleanCodexStderr(client.stderr); + const results = sessionImportResults(completion); + const imported = results?.successes.find((success) => typeof success?.target === "string" && success.target); + // Codex versions that do not report results: use the ledger record this import added. Match + // on path only, because the live transcript can grow after Codex hashed it. + const created = imported + ? null + : importRecordsForSource(canonicalSource) + .filter((record) => !priorThreadIds.has(record.imported_thread_id)) + .at(-1); + const threadId = imported?.target ?? created?.imported_thread_id ?? null; + if (threadId) { + emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId }); + return { + threadId, + alreadyImported: false, + upToDate: null, + importedAt: null, + refreshed: refresh.removed > 0, + ledgerBackupPath: refresh.backupPath, + stderr + }; + } + if (results?.failures.length) { + const detail = results.failures.map((failure) => failure?.message).filter(Boolean).join("; "); + throw new Error(`Codex could not import the Claude session${detail ? `: ${detail}` : "."}${stderr ? `\n${stderr}` : ""}`); + } + // Codex skipped the transcript because it already imported this path. + const previous = options.refresh ? null : priorRecords.at(-1); + if (previous) { + emitProgress(options.onProgress, `Claude session already imported (${previous.imported_thread_id}).`, "completed", { + threadId: previous.imported_thread_id + }); + return { + threadId: previous.imported_thread_id, + alreadyImported: true, + upToDate: previous.content_sha256 === sourceContentSha256(canonicalSource), + importedAt: importedAtIso(previous), + refreshed: false, + ledgerBackupPath: null, + 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."}` + ); + } catch (error) { + // A failed --refresh must not leave the transcript without its earlier import record. + throw withRefreshRestored(error, canonicalSource, refresh); } - 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."}` - ); }); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8b7da914b..2baf2b6a1 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -332,7 +332,8 @@ function setupTransferFixture(behavior) { const ledgerRecords = () => JSON.parse(fs.readFileSync(path.join(codexHome, "external_agent_session_imports.json"), "utf8")).records; const fakeThreads = () => JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")).threads; - return { codexHome, sourcePath, transfer, appendTurn, ledgerRecords, fakeThreads }; + const useBehavior = (nextBehavior) => installFakeCodex(binDir, nextBehavior); + return { codexHome, sourcePath, transfer, appendTurn, ledgerRecords, fakeThreads, useBehavior }; } test("transfer reports an already-imported session instead of failing", () => { @@ -416,6 +417,30 @@ test("transfer keeps an explicit --source in the refresh hint", () => { assert.equal(records.find((record) => record.source_path === fs.realpathSync(olderSource)).imported_thread_id, "thr_3"); }); +test("transfer --refresh restores the earlier import record when Codex rejects the import", () => { + const fixture = setupTransferFixture(); + assert.equal(fixture.transfer("").status, 0); + fixture.appendTurn("More work after the first transfer."); + + for (const behavior of ["external-import-reports-failure", "external-import-unsupported"]) { + fixture.useBehavior(behavior); + const rejected = fixture.transfer("--refresh"); + assert.notEqual(rejected.status, 0, behavior); + assert.match(rejected.stderr, /earlier import record for this transcript was restored \(ledger backup: .+\)/, behavior); + assert.deepEqual( + fixture.ledgerRecords().map((record) => record.imported_thread_id), + ["thr_1"], + behavior + ); + } + + fixture.useBehavior(); + const afterwards = JSON.parse(fixture.transfer("--json").stdout); + assert.equal(afterwards.alreadyImported, true); + assert.equal(afterwards.threadId, "thr_1"); + assert.equal(fixture.fakeThreads().length, 1); +}); + test("transfer surfaces import failures reported by Codex", () => { const fixture = setupTransferFixture("external-import-reports-failure"); diff --git a/tests/transfer-ledger.test.mjs b/tests/transfer-ledger.test.mjs index da536599a..74f78b25f 100644 --- a/tests/transfer-ledger.test.mjs +++ b/tests/transfer-ledger.test.mjs @@ -4,7 +4,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { makeTempDir } from "./helpers.mjs"; -import { forgetImportRecordsForSource } from "../plugins/codex/scripts/lib/codex.mjs"; +import { forgetImportRecordsForSource, restoreImportRecordsForSource } from "../plugins/codex/scripts/lib/codex.mjs"; function importRecord(sourcePath, threadId) { return { @@ -16,17 +16,10 @@ function importRecord(sourcePath, threadId) { }; } -test("--refresh keeps ledger records that another import adds while it rewrites the ledger", (t) => { +function useLedger(t, records) { const codexHome = makeTempDir(); const ledgerPath = path.join(codexHome, "external_agent_session_imports.json"); - fs.writeFileSync( - ledgerPath, - JSON.stringify( - { records: [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")], detected_connector_records: [] }, - null, - 2 - ) - ); + fs.writeFileSync(ledgerPath, JSON.stringify({ records, detected_connector_records: [] }, null, 2)); const previousCodexHome = process.env.CODEX_HOME; process.env.CODEX_HOME = codexHome; t.after(() => { @@ -36,6 +29,14 @@ test("--refresh keeps ledger records that another import adds while it rewrites process.env.CODEX_HOME = previousCodexHome; } }); + return { codexHome, ledgerPath }; +} + +const threadIds = (ledgerPath) => + JSON.parse(fs.readFileSync(ledgerPath, "utf8")).records.map((record) => record.imported_thread_id); + +test("--refresh keeps ledger records that another import adds while it rewrites the ledger", (t) => { + const { codexHome, ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")]); // Codex imports for another session write the ledger without any lock. Land one of those // writes while the refresh is preparing its replacement file. @@ -71,3 +72,24 @@ test("--refresh keeps ledger records that another import adds while it rewrites [] ); }); + +test("a failed --refresh puts the removed records back without dropping other updates", (t) => { + const { ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_a"), importRecord("/b.jsonl", "thr_b")]); + const forgotten = forgetImportRecordsForSource("/a.jsonl"); + assert.deepEqual(forgotten.removedRecords.map((record) => record.imported_thread_id), ["thr_a"]); + + // Another session imports while the refreshed import is running. + const ledger = JSON.parse(fs.readFileSync(ledgerPath, "utf8")); + ledger.records.push(importRecord("/c.jsonl", "thr_c")); + fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2)); + + assert.equal(restoreImportRecordsForSource("/a.jsonl", forgotten.removedRecords), true); + assert.deepEqual(threadIds(ledgerPath), ["thr_b", "thr_c", "thr_a"]); +}); + +test("restoring import records leaves a record that Codex wrote for the source in place", (t) => { + const { ledgerPath } = useLedger(t, [importRecord("/a.jsonl", "thr_new")]); + + assert.equal(restoreImportRecordsForSource("/a.jsonl", [importRecord("/a.jsonl", "thr_old")]), false); + assert.deepEqual(threadIds(ledgerPath), ["thr_new"]); +}); From deae1e62788e5dde028da28a9588e82ad49e5104 Mon Sep 17 00:00:00 2001 From: Ehsan Hesam <121943503+ehsan-parloa@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:16:25 +0100 Subject: [PATCH 5/5] fix(transfer): send the canonical source path to the import The ledger lookups and --refresh use the realpath of the transcript, so send that same path in the import request instead of relying on Codex to resolve it the same way. Co-Authored-By: Claude Opus 5.5 --- plugins/codex/scripts/lib/codex.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 54725af73..fd69fa5cb 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -1169,7 +1169,7 @@ export async function importExternalAgentSession(cwd, options = {}) { emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); let completion; try { - completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); + completion = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(canonicalSource, cwd)); } catch (error) { if (error?.rpcCode === -32601) { throw new Error(