Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,13 @@ Examples:
```bash
/codex:transfer
/codex:transfer --source ~/.claude/projects/-Users-me-repo/<session-id>.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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/commands/transfer.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Transfer the current Claude Code session into a resumable Codex thread
argument-hint: "[--source <claude-jsonl>]"
argument-hint: "[--source <claude-jsonl>] [--refresh]"
disable-model-invocation: true
allowed-tools: Bash(node:*)
---
Expand Down
40 changes: 35 additions & 5 deletions plugins/codex/scripts/codex-companion.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function printUsage() {
" node scripts/codex-companion.mjs review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>]",
" node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base <ref>] [--scope <auto|working-tree|branch>] [focus text]",
" node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model <model|spark>] [--effort <none|minimal|low|medium|high|xhigh>] [prompt]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--json]",
" node scripts/codex-companion.mjs transfer [--source <claude-jsonl>] [--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]"
Expand Down Expand Up @@ -614,24 +614,53 @@ 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.refreshCommand) {
lines.push(`To import the current session into a new Codex thread, run \`${payload.refreshCommand}\`.`);
}
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`;
}

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) });
// 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}`,
sourcePath,
sessionId: path.basename(sourcePath, ".jsonl")
sessionId: path.basename(sourcePath, ".jsonl"),
alreadyImported: result.alreadyImported,
upToDate: result.upToDate,
importedAt: result.importedAt,
refreshCommand,
refreshed: result.refreshed,
ledgerBackupPath: result.ledgerBackupPath
};

return {
Expand Down Expand Up @@ -825,12 +854,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);
}
Expand Down
210 changes: 175 additions & 35 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -658,24 +659,114 @@ 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 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 null;
}
const original = fs.readFileSync(ledgerPath, "utf8");
const ledger = JSON.parse(original);
const records = Array.isArray(ledger?.records) ? ledger.records : [];
const nextRecords = update(records);
if (!nextRecords) {
return null;
}
const tempPath = `${ledgerPath}.tmp-${process.pid}`;
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 = backup ? `${ledgerPath}.bak-${Date.now()}` : null;
if (backupPath) {
fs.writeFileSync(backupPath, original);
}
fs.renameSync(tempPath, ledgerPath);
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}.`;
}
const message = error instanceof Error ? error.message : String(error);
return new Error(`${message}\n${note}`, { cause: error });
}

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) {
Expand Down Expand Up @@ -711,7 +802,7 @@ async function requestExternalAgentSessionImport(client, params) {

client.setNotificationHandler((message) => {
if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
resolveCompleted();
resolveCompleted(message.params ?? null);
return;
}
previousHandler?.(message);
Expand All @@ -722,7 +813,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);
Expand Down Expand Up @@ -1064,31 +1155,80 @@ 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) => {
emitProgress(options.onProgress, "Importing Claude session into Codex.", "transferring");
const priorRecords = importRecordsForSource(canonicalSource);
const priorThreadIds = new Set(priorRecords.map((record) => record.imported_thread_id));
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");
}
try {
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(canonicalSource, 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 threadId = importedThreadIdForSource(options.sourcePath);
if (!threadId) {
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);
}
emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId });
return {
threadId,
stderr: cleanCodexStderr(client.stderr)
};
});
}

Expand Down
Loading