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. diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index fead00cc4..b01318628 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -658,23 +658,36 @@ function sourceContentSha256(sourcePath) { return crypto.createHash("sha256").update(fs.readFileSync(sourcePath)).digest("hex"); } -function importedThreadIdForSource(sourcePath) { +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 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 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; } @@ -703,6 +716,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 +725,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 +736,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 +1080,11 @@ 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); + const previousRecords = sessionImportRecordsForSource(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 +1094,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, 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 f83c96a0d..dfd2d61b7 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" || BEHAVIOR === "external-import-legacy-preread-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,28 @@ rl.on("line", (line) => { saveState(state); saveImportLedger(ledger); } - send({ id: message.id, result: {} }); - send({ method: "externalAgentConfig/import/completed", params: {} }); + 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"); + } + 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..0c2c58324 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,79 @@ 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", "external-import-legacy-ambiguous"]) { + 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") || 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: [{ + 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" || behavior === "external-import-legacy-ambiguous" + ? /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 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");