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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Examples:
/codex:transfer --source ~/.claude/projects/-Users-me-repo/<session-id>.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`

Expand Down
86 changes: 82 additions & 4 deletions plugins/codex/scripts/lib/claude-session-transfer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 <path-to-claude-jsonl>.");
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 <path-to-claude-jsonl>.`
);
}
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}`);
Expand All @@ -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 <path-to-claude-jsonl>."
);
}
75 changes: 74 additions & 1 deletion tests/runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
});

Expand All @@ -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 <path-to-claude-jsonl>/);
});

test("transfer reports an actionable upgrade error when native import is unsupported", () => {
const home = makeTempDir();
const repo = path.join(home, "repo");
Expand Down