diff --git a/README.md b/README.md index 937a3037b..b719c93fe 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Examples: /codex:transfer --source ~/.claude/projects/-Users-me-repo/.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. +The plugin's existing `SessionStart` hook supplies the current transcript path automatically. If that hook state is missing or stale after Claude forks a session during compaction, transfer resolves Claude's current session ID to a unique transcript under `~/.claude/projects`. `--source` is available as a manual override and is required when multiple transcripts share the same session ID. 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:status` diff --git a/plugins/codex/scripts/lib/claude-session-transfer.mjs b/plugins/codex/scripts/lib/claude-session-transfer.mjs index eea0aeba2..7a6e7fa77 100644 --- a/plugins/codex/scripts/lib/claude-session-transfer.mjs +++ b/plugins/codex/scripts/lib/claude-session-transfer.mjs @@ -5,6 +5,8 @@ import path from "node:path"; import { ensureAbsolutePath } from "./fs.mjs"; export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH"; +const CLAUDE_SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID"; +const COMPANION_SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID"; const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); function resolveUserPath(cwd, value) { @@ -17,12 +19,51 @@ function resolveUserPath(cwd, value) { return ensureAbsolutePath(cwd, value); } -export function resolveClaudeSessionPath(cwd, options = {}) { - const requestedPath = options.source || process.env[TRANSCRIPT_PATH_ENV]; - if (!requestedPath) { - throw new Error("Could not identify the current Claude transcript. Retry with --source ."); +function findSessionTranscripts(sessionId) { + if (!sessionId || !/^[a-zA-Z0-9_-]+$/.test(sessionId)) { + return []; + } + + const filename = `${sessionId}.jsonl`; + const matches = []; + const pending = [CLAUDE_PROJECTS_DIR]; + + while (pending.length > 0) { + const directory = pending.pop(); + let entries; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile() && entry.name === filename) { + matches.push(entryPath); + } + } } + return matches; +} + +function resolveSessionIdPath(cwd, sessionId) { + const matches = findSessionTranscripts(sessionId); + if (matches.length === 0) { + return null; + } + if (matches.length > 1) { + throw new Error( + `Multiple Claude transcripts matched session ${sessionId}. Retry with --source .` + ); + } + return resolveTranscriptPath(cwd, matches[0]); +} + +function resolveTranscriptPath(cwd, requestedPath) { const sourcePath = resolveUserPath(cwd, requestedPath); if (path.extname(sourcePath) !== ".jsonl") { throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`); @@ -42,3 +83,40 @@ export function resolveClaudeSessionPath(cwd, options = {}) { } return source; } + +export function resolveClaudeSessionPath(cwd, options = {}) { + if (options.source) { + return resolveTranscriptPath(cwd, options.source); + } + + const claudeSessionId = process.env[CLAUDE_SESSION_ID_ENV]; + if (claudeSessionId) { + const source = resolveSessionIdPath(cwd, claudeSessionId); + if (source) { + return source; + } + } + + const requestedPath = process.env[TRANSCRIPT_PATH_ENV]; + if (requestedPath) { + try { + return resolveTranscriptPath(cwd, requestedPath); + } catch (error) { + if (!(error instanceof Error) || !error.message.startsWith("Claude session file not found:")) { + throw error; + } + } + } + + const companionSessionId = process.env[COMPANION_SESSION_ID_ENV]; + if (companionSessionId && companionSessionId !== claudeSessionId) { + const source = resolveSessionIdPath(cwd, companionSessionId); + if (source) { + return source; + } + } + + throw new Error( + "Could not identify the current Claude transcript. Retry with --source ." + ); +} diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..b15ab0a02 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -221,7 +221,7 @@ test("transfer delegates the current Claude session directly to native import", ...buildEnv(binDir), HOME: home, CODEX_HOME: path.join(home, ".codex"), - CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath + CLAUDE_CODE_SESSION_ID: sessionId } }); @@ -244,6 +244,79 @@ test("transfer delegates the current Claude session directly to native import", ); }); +test("transfer prefers Claude's current session id over stale companion hook state", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const currentSessionId = "sess-current-fork"; + const staleSessionId = "sess-stale-parent"; + const projectDir = path.join(home, ".claude", "projects", "-repo"); + const currentSourcePath = path.join(projectDir, `${currentSessionId}.jsonl`); + const staleSourcePath = path.join(projectDir, `${staleSessionId}.jsonl`); + fs.mkdirSync(repo, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + installFakeCodex(binDir); + initGitRepo(repo); + + fs.writeFileSync( + currentSourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Current fork" } })}\n`, + "utf8" + ); + fs.writeFileSync( + staleSourcePath, + `${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Stale parent" } })}\n`, + "utf8" + ); + + const result = run("node", [SCRIPT, "transfer", "--json"], { + cwd: repo, + env: { + ...buildEnv(binDir), + HOME: home, + CODEX_HOME: path.join(home, ".codex"), + CLAUDE_CODE_SESSION_ID: currentSessionId, + CODEX_COMPANION_SESSION_ID: staleSessionId, + CODEX_COMPANION_TRANSCRIPT_PATH: staleSourcePath + } + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.sessionId, currentSessionId); + assert.equal(payload.sourcePath, fs.realpathSync(currentSourcePath)); +}); + +test("transfer requires an explicit source when a session id matches multiple transcripts", () => { + const home = makeTempDir(); + const repo = path.join(home, "repo"); + const binDir = makeTempDir(); + const sessionId = "sess-ambiguous"; + fs.mkdirSync(repo, { recursive: true }); + installFakeCodex(binDir); + initGitRepo(repo); + + for (const project of ["-repo", "-repo-worktree"]) { + const projectDir = path.join(home, ".claude", "projects", project); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync(path.join(projectDir, `${sessionId}.jsonl`), "{}\n", "utf8"); + } + + const result = run("node", [SCRIPT, "transfer"], { + cwd: repo, + env: { + ...buildEnv(binDir), + HOME: home, + CODEX_HOME: path.join(home, ".codex"), + CLAUDE_CODE_SESSION_ID: sessionId + } + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Multiple Claude transcripts matched session sess-ambiguous/); + assert.match(result.stderr, /--source /); +}); + test("transfer reports an actionable upgrade error when native import is unsupported", () => { const home = makeTempDir(); const repo = path.join(home, "repo");