From a0300d7ef2bc5add64b253a29ef81f06c8e951d5 Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 7 Nov 2026 02:12:46 +0800 Subject: [PATCH 1/3] fix(transfer): resolve imports from completion results --- plugins/codex/scripts/lib/codex.mjs | 25 +++++++++++++---- tests/fake-codex-fixture.mjs | 33 ++++++++++++++++++++-- tests/runtime.test.mjs | 43 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..437e28520 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -658,14 +658,13 @@ function sourceContentSha256(sourcePath) { return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); } -function importedThreadIdForSource(sourcePath) { +function importedThreadIdForSource(sourcePath, contentSha256) { 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( @@ -703,6 +702,7 @@ async function requestExternalAgentSessionImport(client, params) { let timeout = null; let resolveCompleted; let rejectCompleted; + /** @type {Promise["params"]>} */ const completed = new Promise((resolve, reject) => { resolveCompleted = resolve; rejectCompleted = reject; @@ -711,7 +711,7 @@ async function requestExternalAgentSessionImport(client, params) { client.setNotificationHandler((message) => { if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) { - resolveCompleted(); + resolveCompleted(message.params); return; } previousHandler?.(message); @@ -722,7 +722,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); @@ -1066,8 +1066,12 @@ export async function importExternalAgentSession(cwd, options = {}) { return withDirectAppServer(cwd, async (client) => { emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); + const sourcePath = fs.realpathSync(options.sourcePath); + // Claude can append to its transcript while the native import is running. + const contentSha256 = sourceContentSha256(sourcePath); + let completed; try { - await requestExternalAgentSessionImport(client, externalAgentSessionMigration(options.sourcePath, cwd)); + completed = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(sourcePath, cwd)); } catch (error) { if (error?.rpcCode === -32601) { throw new Error( @@ -1077,7 +1081,16 @@ export async function importExternalAgentSession(cwd, options = {}) { } throw error; } - const threadId = importedThreadIdForSource(options.sourcePath); + const sessionResult = completed?.itemTypeResults?.find((result) => result.itemType === "SESSIONS"); + const failure = sessionResult?.failures?.find((result) => result.source == null || result.source === sourcePath); + if (failure) { + throw new Error(`Codex failed to import the Claude session: ${failure.message}`); + } + const imported = sessionResult?.successes?.find((result) => result.source === sourcePath); + // An explicit result must not be replaced by a previous import's ledger entry. + const threadId = Array.isArray(completed?.itemTypeResults) + ? (typeof imported?.target === "string" && imported.target.trim() ? imported.target : null) + : importedThreadIdForSource(sourcePath, contentSha256); if (!threadId) { const stderr = cleanCodexStderr(client.stderr); throw new Error( diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index f83c96a0d..4fa281c34 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -368,6 +368,24 @@ rl.on("line", (line) => { throw new Error("missing external session migration"); } const sourcePath = fs.realpathSync(session.path); + if (["external-import-modern-fails", "external-import-modern-fails-without-source", "external-import-modern-empty"].includes(BEHAVIOR)) { + const importId = "transfer-import-id"; + send({ id: message.id, result: { importId } }); + send({ method: "externalAgentConfig/import/completed", params: { + importId, + itemTypeResults: [{ itemType: "SESSIONS", successes: [], + failures: BEHAVIOR === "external-import-modern-empty" ? [] : [{ + itemType: "SESSIONS", + source: BEHAVIOR === "external-import-modern-fails-without-source" ? null : sourcePath, + message: "Native session import failed" + }] + }] + } }); + break; + } + if (BEHAVIOR === "external-import-modern-appends") { + fs.appendFileSync(sourcePath, JSON.stringify({ type: "progress" }) + "\\n"); + } const contents = fs.readFileSync(sourcePath, "utf8"); const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex"); const ledger = loadImportLedger(); @@ -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-appends" || BEHAVIOR === "external-import-modern-appends") { + fs.appendFileSync(sourcePath, JSON.stringify({ type: "progress" }) + "\\n"); + } + const importId = "transfer-import-id"; + const modern = BEHAVIOR === "external-import-modern-appends"; + send({ id: message.id, result: modern ? { importId } : {} }); + send({ method: "externalAgentConfig/import/completed", params: modern ? { + importId, + itemTypeResults: [{ itemType: "SESSIONS", + successes: [{ itemType: "SESSIONS", source: sourcePath, target: thread.id }], + failures: [] + }] + } : {} }); break; } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..41d5500f9 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -1,4 +1,5 @@ import fs from "node:fs"; +import crypto from "node:crypto"; import path from "node:path"; import test from "node:test"; import assert from "node:assert/strict"; @@ -244,6 +245,48 @@ test("transfer delegates the current Claude session directly to native import", ); }); +for (const behavior of ["external-import-legacy-appends", "external-import-modern-appends", "external-import-modern-fails", "external-import-modern-fails-without-source", "external-import-modern-empty"]) { + test(`transfer handles ${behavior} without trusting a stale transcript hash`, () => { + 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); + const original = `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Preserve this request." } })}\n`; + fs.writeFileSync(sourcePath, original); + const failedImport = behavior === "external-import-modern-empty" || behavior.startsWith("external-import-modern-fails"); + if (failedImport) { + const codexHome = path.join(home, ".codex"); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, "external_agent_session_imports.json"), JSON.stringify({ records: [{ + source_path: fs.realpathSync(sourcePath), + content_sha256: crypto.createHash("sha256").update(original).digest("hex"), + imported_thread_id: "thr_stale" + }] })); + } + + const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), HOME: home, CODEX_HOME: path.join(home, ".codex") } + }); + + if (failedImport) { + // A native failure must win even when a matching ledger record exists. + assert.notEqual(result.status, 0); + assert.match(result.stderr, behavior === "external-import-modern-empty" + ? /did not record an imported thread/ : /Native session import failed/); + } else { + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).threadId, "thr_1"); + assert.ok(fs.readFileSync(sourcePath, "utf8").length > original.length); + } + }); +} + test("transfer reports an actionable upgrade error when native import is unsupported", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); From 99dd90338a86413ca0d77d4159fdc64ea3787824 Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 7 Nov 2026 11:27:33 +0800 Subject: [PATCH 2/3] fix(transfer): resolve legacy imports from new ledger records --- plugins/codex/scripts/lib/codex.mjs | 39 +++++++++++++++++++---------- tests/fake-codex-fixture.mjs | 11 +++++++- tests/runtime.test.mjs | 39 ++++++++++++++++++++++++++--- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 437e28520..b01318628 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -658,22 +658,36 @@ function sourceContentSha256(sourcePath) { return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); } -function importedThreadIdForSource(sourcePath, contentSha256) { +function sessionImportRecordsForSource(sourcePath) { const ledgerPath = path.join(resolveCodexHome(), "external_agent_session_imports.json"); if (!fs.existsSync(ledgerPath)) { - return null; + return []; } const ledger = readJsonFile(ledgerPath); const canonicalSource = fs.realpathSync(sourcePath); 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 records.filter( + (record) => + record?.source_path === canonicalSource && + typeof record?.content_sha256 === "string" && record.content_sha256.length > 0 && + typeof record?.imported_thread_id === "string" && record.imported_thread_id.trim() + ); +} + +function importedThreadIdForSource(sourcePath, previousRecords) { + const records = sessionImportRecordsForSource(sourcePath); + const recordKey = (record) => JSON.stringify([record.content_sha256, record.imported_thread_id]); + const previousKeys = new Set(previousRecords.map(recordKey)); + const newThreadIds = new Set(records + .filter((record) => !previousKeys.has(recordKey(record))) + .map((record) => record.imported_thread_id)); + // Legacy notifications lack a target; prefer this invocation's additions. + if (newThreadIds.size > 0) { + return newThreadIds.size === 1 ? [...newThreadIds][0] : null; + } + // Native deduplication may reuse an existing record for unchanged content. + const contentSha256 = sourceContentSha256(sourcePath); + const match = records.filter((record) => record.content_sha256 === contentSha256).at(-1); return match?.imported_thread_id ?? null; } @@ -1067,8 +1081,7 @@ export async function importExternalAgentSession(cwd, options = {}) { return withDirectAppServer(cwd, async (client) => { emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring"); const sourcePath = fs.realpathSync(options.sourcePath); - // Claude can append to its transcript while the native import is running. - const contentSha256 = sourceContentSha256(sourcePath); + const previousRecords = sessionImportRecordsForSource(sourcePath); let completed; try { completed = await requestExternalAgentSessionImport(client, externalAgentSessionMigration(sourcePath, cwd)); @@ -1090,7 +1103,7 @@ export async function importExternalAgentSession(cwd, options = {}) { // An explicit result must not be replaced by a previous import's ledger entry. const threadId = Array.isArray(completed?.itemTypeResults) ? (typeof imported?.target === "string" && imported.target.trim() ? imported.target : null) - : importedThreadIdForSource(sourcePath, contentSha256); + : importedThreadIdForSource(sourcePath, previousRecords); if (!threadId) { const stderr = cleanCodexStderr(client.stderr); throw new Error( diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index 4fa281c34..dfd2d61b7 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -383,7 +383,7 @@ rl.on("line", (line) => { } }); break; } - if (BEHAVIOR === "external-import-modern-appends") { + if (BEHAVIOR === "external-import-modern-appends" || BEHAVIOR === "external-import-legacy-preread-appends") { fs.appendFileSync(sourcePath, JSON.stringify({ type: "progress" }) + "\\n"); } const contents = fs.readFileSync(sourcePath, "utf8"); @@ -417,6 +417,15 @@ rl.on("line", (line) => { saveState(state); saveImportLedger(ledger); } + if (BEHAVIOR === "external-import-legacy-ambiguous") { + const concurrentThread = nextThread(state, session.cwd, false); + ledger.records.push({ ...record, + content_sha256: crypto.createHash("sha256").update(contents + "concurrent import").digest("hex"), + imported_thread_id: concurrentThread.id + }); + saveState(state); + saveImportLedger(ledger); + } if (BEHAVIOR === "external-import-legacy-appends" || BEHAVIOR === "external-import-modern-appends") { fs.appendFileSync(sourcePath, JSON.stringify({ type: "progress" }) + "\\n"); } diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 41d5500f9..0c2c58324 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -245,7 +245,7 @@ test("transfer delegates the current Claude session directly to native import", ); }); -for (const behavior of ["external-import-legacy-appends", "external-import-modern-appends", "external-import-modern-fails", "external-import-modern-fails-without-source", "external-import-modern-empty"]) { +for (const behavior of ["external-import-legacy-appends", "external-import-modern-appends", "external-import-modern-fails", "external-import-modern-fails-without-source", "external-import-modern-empty", "external-import-legacy-ambiguous"]) { test(`transfer handles ${behavior} without trusting a stale transcript hash`, () => { const home = makeTempDir(); const repo = path.join(home, "repo"); @@ -258,8 +258,8 @@ for (const behavior of ["external-import-legacy-appends", "external-import-moder initGitRepo(repo); const original = `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Preserve this request." } })}\n`; fs.writeFileSync(sourcePath, original); - const failedImport = behavior === "external-import-modern-empty" || behavior.startsWith("external-import-modern-fails"); - if (failedImport) { + const failedImport = behavior === "external-import-modern-empty" || behavior.startsWith("external-import-modern-fails") || behavior === "external-import-legacy-ambiguous"; + if (failedImport && behavior !== "external-import-legacy-ambiguous") { const codexHome = path.join(home, ".codex"); fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync(path.join(codexHome, "external_agent_session_imports.json"), JSON.stringify({ records: [{ @@ -277,7 +277,7 @@ for (const behavior of ["external-import-legacy-appends", "external-import-moder if (failedImport) { // A native failure must win even when a matching ledger record exists. assert.notEqual(result.status, 0); - assert.match(result.stderr, behavior === "external-import-modern-empty" + assert.match(result.stderr, behavior === "external-import-modern-empty" || behavior === "external-import-legacy-ambiguous" ? /did not record an imported thread/ : /Native session import failed/); } else { assert.equal(result.status, 0, result.stderr); @@ -287,6 +287,37 @@ for (const behavior of ["external-import-legacy-appends", "external-import-moder }); } +test("transfer returns the new legacy import when the transcript grows before the importer reads it", () => { + 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 }); + initGitRepo(repo); + installFakeCodex(binDir); + fs.writeFileSync(sourcePath, `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Preserve the latest history." } })}\n`); + const transfer = () => run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], { + cwd: repo, + env: { ...buildEnv(binDir), HOME: home, CODEX_HOME: path.join(home, ".codex") } + }); + for (const expectedThread of ["thr_1", "thr_2"]) { + if (expectedThread === "thr_2") { + installFakeCodex(binDir, "external-import-legacy-preread-appends"); + } + const result = transfer(); + assert.equal(result.status, 0, result.stderr); + const state = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.equal(state.lastExternalAgentImport.threadId, expectedThread); + assert.equal(JSON.parse(result.stdout).threadId, expectedThread); + } + installFakeCodex(binDir); + const deduplicated = transfer(); + assert.equal(deduplicated.status, 0, deduplicated.stderr); + assert.equal(JSON.parse(deduplicated.stdout).threadId, "thr_2"); +}); + test("transfer reports an actionable upgrade error when native import is unsupported", () => { const home = makeTempDir(); const repo = path.join(home, "repo"); From 9069ba73e460921284d710796dbe5a47fd6c2e6a Mon Sep 17 00:00:00 2001 From: daymade Date: Sat, 7 Nov 2026 11:27:46 +0800 Subject: [PATCH 3/3] docs: clarify transfer recovery and contributor validation --- CLAUDE.md | 7 +++++++ README.md | 22 ++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..982fc1572 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# Development guidance + +Keep user-facing transfer instructions and recovery guidance in [README.md](README.md#codextransfer). + +When changing session transfer, verify that the command returns the thread containing the imported history and that this thread can be resumed. Exercise the command-level regressions, including transcript growth before and after the native read, repeat imports, and failure handling. An import-completed notification alone does not prove that the returned thread is usable. + +Use the test and build scripts defined in [package.json](package.json). When running tests from an active Claude Code session, clear inherited `CLAUDE_PLUGIN_DATA`, `CODEX_COMPANION_SESSION_ID`, and `CODEX_COMPANION_TRANSCRIPT_PATH` from the test process; they otherwise change fixture storage or job selection. Keep the live Claude session's environment intact. diff --git a/README.md b/README.md index 937a3037b..c281a78ae 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,6 @@ Use Codex from inside Claude Code for code reviews or to delegate tasks to Codex This plugin is for Claude Code users who want an easy way to start using Codex from the workflow they already have. - - -## What You Get - -- `/codex:review` for a normal read-only Codex review -- `/codex:adversarial-review` for a steerable challenge review -- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs - ## Requirements - **ChatGPT subscription (incl. Free) or OpenAI API key.** @@ -172,11 +164,21 @@ Examples: ```bash /codex:transfer -/codex:transfer --source ~/.claude/projects/-Users-me-repo/.jsonl +/codex:transfer --source ~/.claude/projects//.jsonl ``` 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. +After a successful transfer, run the printed command in a terminal: + +```bash +codex resume +``` + +Use the Codex session ID returned by that transfer, not the Claude session ID in the source filename. A transcript can grow during import, so an ID from an earlier transfer may lack the latest history. When Codex supplies per-item import results, a failed import or a result without a matching success is reported as an error rather than replaced with an older thread. + +For older Codex versions without per-item results, the plugin checks records added for the source during the import, or an exact current-content match when Codex reuses an existing import. Multiple new thread IDs are treated as ambiguous. Avoid parallel transfers of the same source with these versions; if the result cannot be resolved, update Codex and retry instead of choosing a thread from the import ledger manually. + ### `/codex:status` Shows running and recent Codex jobs for the current repository. @@ -287,7 +289,7 @@ Check out the Codex docs for more [configuration options](https://developers.ope ### Moving The Work Over To Codex -Delegated tasks and any [stop gate](#what-does-the-review-gate-do) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list. +Delegated tasks and any [stop gate](#enabling-review-gate) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list. This way you can review the Codex work or continue the work there.