feat: add Claude session transfer command (#374)
This commit is contained in:
parent
807e03ac9d
commit
430e2a8630
17
README.md
17
README.md
@ -11,7 +11,7 @@ they already have.
|
||||
|
||||
- `/codex:review` for a normal read-only Codex review
|
||||
- `/codex:adversarial-review` for a steerable challenge review
|
||||
- `/codex:rescue`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work and manage background jobs
|
||||
- `/codex:rescue`, `/codex:transfer`, `/codex:status`, `/codex:result`, and `/codex:cancel` to delegate work, hand off sessions, and manage background jobs
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -162,6 +162,21 @@ Ask Codex to redesign the database connection to be more resilient.
|
||||
- if you say `spark`, the plugin maps that to `gpt-5.3-codex-spark`
|
||||
- follow-up rescue requests can continue the latest Codex task in the repo
|
||||
|
||||
### `/codex:transfer`
|
||||
|
||||
Creates a persistent Codex thread from the current Claude Code session and prints a `codex resume <session-id>` command.
|
||||
|
||||
Use it when you started a debugging or implementation conversation in Claude Code and want to continue that same context directly in Codex.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
/codex:transfer
|
||||
/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.
|
||||
|
||||
### `/codex:status`
|
||||
|
||||
Shows running and recent Codex jobs for the current repository.
|
||||
|
||||
10
plugins/codex/commands/transfer.md
Normal file
10
plugins/codex/commands/transfer.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
description: Transfer the current Claude Code session into a resumable Codex thread
|
||||
argument-hint: "[--source <claude-jsonl>]"
|
||||
disable-model-invocation: true
|
||||
allowed-tools: Bash(node:*)
|
||||
---
|
||||
|
||||
!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"`
|
||||
|
||||
Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume <session-id>` command.
|
||||
@ -14,12 +14,14 @@ import {
|
||||
getCodexAuthStatus,
|
||||
getCodexAvailability,
|
||||
getSessionRuntimeStatus,
|
||||
importExternalAgentSession,
|
||||
interruptAppServerTurn,
|
||||
parseStructuredOutput,
|
||||
readOutputSchema,
|
||||
runAppServerReview,
|
||||
runAppServerTurn
|
||||
} from "./lib/codex.mjs";
|
||||
import { resolveClaudeSessionPath } from "./lib/claude-session-transfer.mjs";
|
||||
import { readStdinIfPiped } from "./lib/fs.mjs";
|
||||
import { collectReviewContext, ensureGitRepository, resolveReviewTarget } from "./lib/git.mjs";
|
||||
import { binaryAvailable, terminateProcessTree } from "./lib/process.mjs";
|
||||
@ -78,6 +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 status [job-id] [--all] [--json]",
|
||||
" node scripts/codex-companion.mjs result [job-id] [--json]",
|
||||
" node scripts/codex-companion.mjs cancel [job-id] [--json]"
|
||||
@ -610,6 +613,33 @@ function buildTaskRequest({ cwd, model, effort, prompt, write, resumeLast, jobId
|
||||
};
|
||||
}
|
||||
|
||||
function renderTransferResult(payload) {
|
||||
const lines = [
|
||||
"Transferred the Claude session into a Codex thread with visible turn history.",
|
||||
`Codex session ID: ${payload.threadId}`,
|
||||
`Resume in Codex: ${payload.resumeCommand}`
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function executeTransfer(cwd, options = {}) {
|
||||
const sourcePath = resolveClaudeSessionPath(cwd, {
|
||||
source: options.source
|
||||
});
|
||||
const result = await importExternalAgentSession(cwd, { sourcePath });
|
||||
const payload = {
|
||||
threadId: result.threadId,
|
||||
resumeCommand: `codex resume ${result.threadId}`,
|
||||
sourcePath,
|
||||
sessionId: path.basename(sourcePath, ".jsonl")
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
rendered: renderTransferResult(payload)
|
||||
};
|
||||
}
|
||||
|
||||
function readTaskPrompt(cwd, options, positionals) {
|
||||
if (options["prompt-file"]) {
|
||||
return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8");
|
||||
@ -792,6 +822,19 @@ async function handleTask(argv) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTransfer(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd", "source"],
|
||||
booleanOptions: ["json"]
|
||||
});
|
||||
|
||||
const cwd = resolveCommandCwd(options);
|
||||
const { payload, rendered } = await executeTransfer(cwd, {
|
||||
source: options.source
|
||||
});
|
||||
outputCommandResult(payload, rendered, options.json);
|
||||
}
|
||||
|
||||
async function handleTaskWorker(argv) {
|
||||
const { options } = parseCommandInput(argv, {
|
||||
valueOptions: ["cwd", "job-id"]
|
||||
@ -1000,6 +1043,9 @@ async function main() {
|
||||
case "task":
|
||||
await handleTask(argv);
|
||||
break;
|
||||
case "transfer":
|
||||
await handleTransfer(argv);
|
||||
break;
|
||||
case "task-worker":
|
||||
await handleTaskWorker(argv);
|
||||
break;
|
||||
|
||||
@ -6,6 +6,8 @@ import type {
|
||||
ServerNotification
|
||||
} from "../../.generated/app-server-types/index.js";
|
||||
import type {
|
||||
ExternalAgentConfigImportParams,
|
||||
ExternalAgentConfigImportResponse,
|
||||
ReviewStartParams,
|
||||
ReviewStartResponse,
|
||||
ReviewTarget,
|
||||
@ -56,6 +58,7 @@ export interface CodexAppServerClientOptions {
|
||||
|
||||
export interface AppServerMethodMap {
|
||||
initialize: { params: InitializeParams; result: InitializeResponse };
|
||||
"externalAgentConfig/import": { params: ExternalAgentConfigImportParams; result: ExternalAgentConfigImportResponse };
|
||||
"thread/start": { params: ThreadStartParams; result: ThreadStartResponse };
|
||||
"thread/resume": { params: ThreadResumeParams; result: ThreadResumeResponse };
|
||||
"thread/name/set": { params: ThreadSetNameParams; result: ThreadSetNameResponse };
|
||||
|
||||
@ -32,6 +32,7 @@ const DEFAULT_CLIENT_INFO = {
|
||||
/** @type {InitializeCapabilities} */
|
||||
const DEFAULT_CAPABILITIES = {
|
||||
experimentalApi: false,
|
||||
requestAttestation: false,
|
||||
optOutNotificationMethods: [
|
||||
"item/agentMessage/delta",
|
||||
"item/reasoning/summaryTextDelta",
|
||||
@ -206,10 +207,13 @@ class SpawnedCodexAppServerClient extends AppServerClientBase {
|
||||
});
|
||||
|
||||
this.proc.on("exit", (code, signal) => {
|
||||
const stderr = this.stderr.trim();
|
||||
const detail =
|
||||
code === 0
|
||||
? null
|
||||
: createProtocolError(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).`);
|
||||
: createProtocolError(
|
||||
`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${stderr ? `\n${stderr}` : ""}`
|
||||
);
|
||||
this.handleExit(detail);
|
||||
});
|
||||
|
||||
|
||||
44
plugins/codex/scripts/lib/claude-session-transfer.mjs
Normal file
44
plugins/codex/scripts/lib/claude-session-transfer.mjs
Normal file
@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { ensureAbsolutePath } from "./fs.mjs";
|
||||
|
||||
export const TRANSCRIPT_PATH_ENV = "CODEX_COMPANION_TRANSCRIPT_PATH";
|
||||
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
||||
|
||||
function resolveUserPath(cwd, value) {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (String(value).startsWith("~/")) {
|
||||
return path.join(os.homedir(), String(value).slice(2));
|
||||
}
|
||||
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>.");
|
||||
}
|
||||
|
||||
const sourcePath = resolveUserPath(cwd, requestedPath);
|
||||
if (path.extname(sourcePath) !== ".jsonl") {
|
||||
throw new Error(`Claude session source must be a JSONL file: ${sourcePath}`);
|
||||
}
|
||||
|
||||
let source;
|
||||
let projects;
|
||||
try {
|
||||
source = fs.realpathSync(sourcePath);
|
||||
projects = fs.realpathSync(CLAUDE_PROJECTS_DIR);
|
||||
} catch {
|
||||
throw new Error(`Claude session file not found: ${sourcePath}`);
|
||||
}
|
||||
const relative = path.relative(projects, source);
|
||||
if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Codex can import Claude sessions only from ${CLAUDE_PROJECTS_DIR}: ${source}`);
|
||||
}
|
||||
return source;
|
||||
}
|
||||
@ -34,6 +34,11 @@
|
||||
* onProgress: ProgressReporter | null
|
||||
* }} TurnCaptureState
|
||||
*/
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { readJsonFile } from "./fs.mjs";
|
||||
import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs";
|
||||
import { loadBrokerSession } from "./broker-lifecycle.mjs";
|
||||
@ -43,6 +48,8 @@ const SERVICE_NAME = "claude_code_codex_plugin";
|
||||
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 EXTERNAL_AGENT_IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
function cleanCodexStderr(stderr) {
|
||||
return stderr
|
||||
@ -60,8 +67,7 @@ function buildThreadParams(cwd, options = {}) {
|
||||
approvalPolicy: options.approvalPolicy ?? "never",
|
||||
sandbox: options.sandbox ?? "read-only",
|
||||
serviceName: SERVICE_NAME,
|
||||
ephemeral: options.ephemeral ?? true,
|
||||
experimentalRawEvents: false
|
||||
ephemeral: options.ephemeral ?? true
|
||||
};
|
||||
}
|
||||
|
||||
@ -635,6 +641,94 @@ async function withAppServer(cwd, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
async function withDirectAppServer(cwd, fn) {
|
||||
const client = await CodexAppServerClient.connect(cwd, { disableBroker: true });
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexHome() {
|
||||
return path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
|
||||
}
|
||||
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
function externalAgentSessionMigration(sourcePath, cwd) {
|
||||
return {
|
||||
migrationItems: [
|
||||
{
|
||||
itemType: "SESSIONS",
|
||||
description: `Transfer Claude session ${path.basename(sourcePath)}`,
|
||||
cwd: null,
|
||||
details: {
|
||||
plugins: [],
|
||||
sessions: [{ path: sourcePath, cwd, title: null }],
|
||||
mcpServers: [],
|
||||
hooks: [],
|
||||
subagents: [],
|
||||
commands: []
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function requestExternalAgentSessionImport(client, params) {
|
||||
const previousHandler = client.notificationHandler;
|
||||
let timeout = null;
|
||||
let resolveCompleted;
|
||||
let rejectCompleted;
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
resolveCompleted = resolve;
|
||||
rejectCompleted = reject;
|
||||
});
|
||||
void completed.catch(() => {});
|
||||
|
||||
client.setNotificationHandler((message) => {
|
||||
if (message.method === EXTERNAL_AGENT_IMPORT_COMPLETED) {
|
||||
resolveCompleted();
|
||||
return;
|
||||
}
|
||||
previousHandler?.(message);
|
||||
});
|
||||
timeout = setTimeout(() => {
|
||||
rejectCompleted(new Error("Timed out waiting for Codex to finish importing the Claude session."));
|
||||
}, EXTERNAL_AGENT_IMPORT_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
await client.request("externalAgentConfig/import", params);
|
||||
await completed;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
client.setNotificationHandler(previousHandler ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
async function startThread(client, cwd, options = {}) {
|
||||
const response = await client.request("thread/start", buildThreadParams(cwd, options));
|
||||
const threadId = response.thread.id;
|
||||
@ -961,6 +1055,43 @@ export async function runAppServerReview(cwd, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function importExternalAgentSession(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
throw new Error("Codex CLI is not installed or is missing required runtime support. Install it with `npm install -g @openai/codex`, then rerun `/codex:setup`.");
|
||||
}
|
||||
if (!options.sourcePath) {
|
||||
throw new Error("A Claude session source path is required.");
|
||||
}
|
||||
|
||||
return withDirectAppServer(cwd, async (client) => {
|
||||
emitProgress(options.onProgress, "Importing Claude session into Codex.", "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 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const threadId = importedThreadIdForSource(options.sourcePath);
|
||||
if (!threadId) {
|
||||
const stderr = cleanCodexStderr(client.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."}`
|
||||
);
|
||||
}
|
||||
emitProgress(options.onProgress, `Claude session imported (${threadId}).`, "completed", { threadId });
|
||||
return {
|
||||
threadId,
|
||||
stderr: cleanCodexStderr(client.stderr)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function runAppServerTurn(cwd, options = {}) {
|
||||
const availability = getCodexAvailability(cwd);
|
||||
if (!availability.available) {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
teardownBrokerSession
|
||||
} from "./lib/broker-lifecycle.mjs";
|
||||
import { loadState, resolveStateFile, saveState } from "./lib/state.mjs";
|
||||
import { TRANSCRIPT_PATH_ENV } from "./lib/claude-session-transfer.mjs";
|
||||
import { resolveWorkspaceRoot } from "./lib/workspace.mjs";
|
||||
|
||||
export const SESSION_ID_ENV = "CODEX_COMPANION_SESSION_ID";
|
||||
@ -75,6 +76,7 @@ function cleanupSessionJobs(cwd, sessionId) {
|
||||
|
||||
function handleSessionStart(input) {
|
||||
appendEnvVar(SESSION_ID_ENV, input.session_id);
|
||||
appendEnvVar(TRANSCRIPT_PATH_ENV, input.transcript_path);
|
||||
appendEnvVar(PLUGIN_DATA_ENV, process.env[PLUGIN_DATA_ENV]);
|
||||
}
|
||||
|
||||
|
||||
@ -79,7 +79,8 @@ test("continue is not exposed as a user-facing command", () => {
|
||||
"result.md",
|
||||
"review.md",
|
||||
"setup.md",
|
||||
"status.md"
|
||||
"status.md",
|
||||
"transfer.md"
|
||||
]);
|
||||
});
|
||||
|
||||
@ -163,16 +164,21 @@ test("rescue command absorbs continue semantics", () => {
|
||||
assert.match(readme, /uses the same review target selection as `\/codex:review`/i);
|
||||
assert.match(readme, /--base main challenge whether this was the right caching and retry design/);
|
||||
assert.match(readme, /### `\/codex:rescue`/);
|
||||
assert.match(readme, /### `\/codex:transfer`/);
|
||||
assert.match(readme, /### `\/codex:status`/);
|
||||
assert.match(readme, /### `\/codex:result`/);
|
||||
assert.match(readme, /### `\/codex:cancel`/);
|
||||
});
|
||||
|
||||
test("result and cancel commands are exposed as deterministic runtime entrypoints", () => {
|
||||
test("transfer, result, and cancel commands are exposed as deterministic runtime entrypoints", () => {
|
||||
const transfer = read("commands/transfer.md");
|
||||
const result = read("commands/result.md");
|
||||
const cancel = read("commands/cancel.md");
|
||||
const resultHandling = read("skills/codex-result-handling/SKILL.md");
|
||||
|
||||
assert.match(transfer, /disable-model-invocation:\s*true/);
|
||||
assert.match(transfer, /codex-companion\.mjs" transfer "\$ARGUMENTS"/);
|
||||
assert.match(transfer, /codex resume <session-id>/);
|
||||
assert.match(result, /disable-model-invocation:\s*true/);
|
||||
assert.match(result, /codex-companion\.mjs" result "\$ARGUMENTS"/);
|
||||
assert.match(cancel, /disable-model-invocation:\s*true/);
|
||||
|
||||
@ -9,6 +9,7 @@ export function installFakeCodex(binDir, behavior = "review-ok") {
|
||||
const scriptPath = path.join(binDir, "codex");
|
||||
const source = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const crypto = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline");
|
||||
|
||||
@ -144,6 +145,21 @@ function nextTurnId(state) {
|
||||
return turnId;
|
||||
}
|
||||
|
||||
function importLedgerPath() {
|
||||
return path.join(process.env.CODEX_HOME || path.join(process.env.HOME, ".codex"), "external_agent_session_imports.json");
|
||||
}
|
||||
|
||||
function loadImportLedger() {
|
||||
const ledgerPath = importLedgerPath();
|
||||
return fs.existsSync(ledgerPath) ? JSON.parse(fs.readFileSync(ledgerPath, "utf8")) : { records: [] };
|
||||
}
|
||||
|
||||
function saveImportLedger(ledger) {
|
||||
const ledgerPath = importLedgerPath();
|
||||
fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
||||
fs.writeFileSync(ledgerPath, JSON.stringify(ledger, null, 2));
|
||||
}
|
||||
|
||||
function emitTurnCompleted(threadId, turnId, item) {
|
||||
const items = Array.isArray(item) ? item : [item];
|
||||
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
|
||||
@ -335,6 +351,59 @@ rl.on("line", (line) => {
|
||||
break;
|
||||
}
|
||||
|
||||
case "externalAgentConfig/import": {
|
||||
if (BEHAVIOR === "external-import-unsupported") {
|
||||
send({ id: message.id, error: { code: -32601, message: "Unsupported method: externalAgentConfig/import" } });
|
||||
break;
|
||||
}
|
||||
if (BEHAVIOR === "external-import-fails") {
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
const sessions = (message.params.migrationItems || [])
|
||||
.flatMap((item) => item.details && Array.isArray(item.details.sessions) ? item.details.sessions : []);
|
||||
const session = sessions[0];
|
||||
if (!session) {
|
||||
throw new Error("missing external session migration");
|
||||
}
|
||||
const sourcePath = fs.realpathSync(session.path);
|
||||
const contents = fs.readFileSync(sourcePath, "utf8");
|
||||
const contentSha256 = crypto.createHash("sha256").update(contents).digest("hex");
|
||||
const ledger = loadImportLedger();
|
||||
let record = ledger.records.find(
|
||||
(candidate) => candidate.source_path === sourcePath && candidate.content_sha256 === contentSha256
|
||||
);
|
||||
let thread;
|
||||
if (record) {
|
||||
thread = ensureThread(state, record.imported_thread_id);
|
||||
} else {
|
||||
const records = contents.split(/\\r?\\n/).filter(Boolean).map((line) => JSON.parse(line));
|
||||
const title = records.find((entry) => entry.type === "custom-title")?.customTitle || null;
|
||||
const messages = records
|
||||
.filter((entry) => entry.type === "user" || entry.type === "assistant")
|
||||
.map((entry) => ({ role: entry.type, text: entry.message?.content || "" }));
|
||||
thread = nextThread(state, session.cwd, false);
|
||||
thread.name = title;
|
||||
thread.preview = messages.find((entry) => entry.role === "user")?.text || "";
|
||||
thread.visibleMessages = messages;
|
||||
state.lastExternalAgentImport = { sourcePath, threadId: thread.id, messages };
|
||||
record = {
|
||||
source_path: sourcePath,
|
||||
content_sha256: contentSha256,
|
||||
imported_thread_id: thread.id,
|
||||
imported_at: now(),
|
||||
source_modified_at: null
|
||||
};
|
||||
ledger.records.push(record);
|
||||
saveState(state);
|
||||
saveImportLedger(ledger);
|
||||
}
|
||||
send({ id: message.id, result: {} });
|
||||
send({ method: "externalAgentConfig/import/completed", params: {} });
|
||||
break;
|
||||
}
|
||||
|
||||
case "review/start": {
|
||||
const thread = ensureThread(state, message.params.threadId);
|
||||
let reviewThread = thread;
|
||||
|
||||
@ -193,6 +193,140 @@ test("task runs without auth preflight so Codex can refresh an expired session",
|
||||
assert.match(result.stdout, /Handled the requested task/);
|
||||
});
|
||||
|
||||
test("transfer delegates the current Claude session directly to native import", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const sessionId = "sess-native-transfer";
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
const projectDir = path.join(home, ".claude", "projects", "-repo");
|
||||
const sourcePath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
[
|
||||
{ type: "custom-title", customTitle: "Native transfer" },
|
||||
{ type: "user", cwd: repo, message: { role: "user", content: "Initial request" } },
|
||||
{ type: "assistant", cwd: repo, message: { role: "assistant", content: "Initial answer" } },
|
||||
{ type: "user", cwd: repo, message: { role: "user", content: "/codex:transfer" } }
|
||||
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
||||
"utf8"
|
||||
);
|
||||
const result = run("node", [SCRIPT, "transfer", "--json"], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex"),
|
||||
CODEX_COMPANION_TRANSCRIPT_PATH: sourcePath
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
const canonicalSourcePath = fs.realpathSync(sourcePath);
|
||||
assert.equal(payload.threadId, "thr_1");
|
||||
assert.equal(payload.resumeCommand, "codex resume thr_1");
|
||||
assert.equal(payload.sourcePath, canonicalSourcePath);
|
||||
assert.equal(payload.sessionId, sessionId);
|
||||
|
||||
const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8"));
|
||||
assert.equal(fakeState.threads.length, 1);
|
||||
assert.equal(fakeState.threads[0].ephemeral, false);
|
||||
assert.equal(fakeState.threads[0].name, "Native transfer");
|
||||
assert.equal(fakeState.lastExternalAgentImport.sourcePath, canonicalSourcePath);
|
||||
assert.deepEqual(
|
||||
fakeState.threads[0].visibleMessages.map((message) => message.text),
|
||||
["Initial request", "Initial answer", "/codex:transfer"]
|
||||
);
|
||||
});
|
||||
|
||||
test("transfer reports an actionable upgrade error when native import is unsupported", () => {
|
||||
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, "external-import-unsupported");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Continue this work." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath, "--json"], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex")
|
||||
}
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /does not support Claude session transfer/);
|
||||
assert.match(result.stderr, /@openai\/codex@latest/);
|
||||
});
|
||||
|
||||
test("transfer fails visibly when native import completes without a ledger record", () => {
|
||||
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, "external-import-fails");
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Do not lose this request." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
|
||||
cwd: repo,
|
||||
env: {
|
||||
...buildEnv(binDir),
|
||||
HOME: home,
|
||||
CODEX_HOME: path.join(home, ".codex")
|
||||
}
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /did not record an imported thread/);
|
||||
});
|
||||
|
||||
test("transfer rejects sources outside the Claude projects directory", () => {
|
||||
const home = makeTempDir();
|
||||
const repo = path.join(home, "repo");
|
||||
const binDir = makeTempDir();
|
||||
const sourcePath = path.join(home, "session.jsonl");
|
||||
fs.mkdirSync(repo, { recursive: true });
|
||||
fs.mkdirSync(path.join(home, ".claude", "projects"), { recursive: true });
|
||||
installFakeCodex(binDir);
|
||||
initGitRepo(repo);
|
||||
fs.writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({ type: "user", cwd: repo, message: { role: "user", content: "Outside source." } })}\n`,
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const result = run("node", [SCRIPT, "transfer", "--source", sourcePath], {
|
||||
cwd: repo,
|
||||
env: { ...buildEnv(binDir), HOME: home }
|
||||
});
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /only from .*\.claude.*projects/);
|
||||
});
|
||||
|
||||
test("task reports the actual Codex auth error when the run is rejected", () => {
|
||||
const repo = makeTempDir();
|
||||
const binDir = makeTempDir();
|
||||
@ -535,11 +669,12 @@ test("task --resume-last ignores running tasks from other Claude sessions", () =
|
||||
assert.match(resume.stderr, /No previous Codex task thread was found for this repository\./);
|
||||
});
|
||||
|
||||
test("session start hook exports the Claude session id and plugin data dir for later commands", () => {
|
||||
test("session start hook exports the Claude session id, transcript path, and plugin data dir", () => {
|
||||
const repo = makeTempDir();
|
||||
const envFile = path.join(makeTempDir(), "claude-env.sh");
|
||||
fs.writeFileSync(envFile, "", "utf8");
|
||||
const pluginDataDir = makeTempDir();
|
||||
const transcriptPath = path.join(repo, "session.jsonl");
|
||||
|
||||
const result = run("node", [SESSION_HOOK, "SessionStart"], {
|
||||
cwd: repo,
|
||||
@ -551,6 +686,7 @@ test("session start hook exports the Claude session id and plugin data dir for l
|
||||
input: JSON.stringify({
|
||||
hook_event_name: "SessionStart",
|
||||
session_id: "sess-current",
|
||||
transcript_path: transcriptPath,
|
||||
cwd: repo
|
||||
})
|
||||
});
|
||||
@ -558,7 +694,7 @@ test("session start hook exports the Claude session id and plugin data dir for l
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
fs.readFileSync(envFile, "utf8"),
|
||||
`export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
|
||||
`export CODEX_COMPANION_SESSION_ID='sess-current'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user