diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index c6aac98731..ea7c53811c 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -221,7 +221,10 @@ export function ResumeConversation({ } } if (result_3.sessionId && !forkSession) { - switchSession(asSessionId(result_3.sessionId), log_0.fullPath ? dirname(log_0.fullPath) : null); + // Prefer the resolved fullPath: for imported sessions (Codex) it + // points to the converted native file, not the foreign source. + const sessionDirPath = result_3.fullPath ?? log_0.fullPath; + switchSession(asSessionId(result_3.sessionId), sessionDirPath ? dirname(sessionDirPath) : null); await renameRecordingForSession(); await resetSessionFilePointer(); restoreCostStateForSession(result_3.sessionId); diff --git a/src/types/logs.ts b/src/types/logs.ts index 1515932612..10379515bb 100644 --- a/src/types/logs.ts +++ b/src/types/logs.ts @@ -38,6 +38,7 @@ export type LogOption = { summary?: string // Optional conversation summary customTitle?: string // Optional user-set custom title tag?: string // Optional tag for the session (searchable in /resume) + importedFrom?: 'codex' // Sessão importada de outro CLI (conversão no resume) fileHistorySnapshots?: FileHistorySnapshot[] // Optional file history snapshots attributionSnapshots?: AttributionSnapshotMessage[] // Optional attribution snapshots contextCollapseCommits?: ContextCollapseCommitEntry[] // Ordered — commit B may reference commit A's summary diff --git a/src/utils/codexSessionImport.test.ts b/src/utils/codexSessionImport.test.ts new file mode 100644 index 0000000000..120cf17c1c --- /dev/null +++ b/src/utils/codexSessionImport.test.ts @@ -0,0 +1,394 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { + convertCodexRollout, + getCodexLogOptions, + listCodexRollouts, + readCodexFirstPrompt, +} from './codexSessionImport.js' + +const SESSION_ID = '01a0ca5a-1730-7813-a5cb-04967a29fd8f' +const PROJECT_CWD = '/home/user/proj-x' + +function rolloutLine( + type: string, + payload: Record, + timestamp = '2026-09-22T18:23:25.315Z', +): string { + return JSON.stringify({ timestamp, type, payload }) +} + +function writeRollout(path: string, lines: string[]): void { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, lines.join('\n') + '\n') +} + +function baseRolloutLines(): string[] { + return [ + rolloutLine('session_meta', { + session_id: SESSION_ID, + id: SESSION_ID, + cwd: PROJECT_CWD, + timestamp: '2026-09-22T18:21:36.944Z', + cli_version: '0.155.1', + }), + rolloutLine('event_msg', { type: 'task_started' }), + // prompt de usuário "wrapper" — deve ser ignorado + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'cwd' }], + }), + // primeiro prompt real + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'arruma o bug do checkout' }], + }), + // instrução de developer — ignorada + rolloutLine('response_item', { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: '...' }], + }), + // resposta do assistente + rolloutLine('response_item', { + type: 'message', + id: 'msg_abc123', + role: 'assistant', + content: [{ type: 'output_text', text: 'vou investigar o checkout' }], + }), + // tool call + output + rolloutLine('response_item', { + type: 'custom_tool_call', + id: 'ctc_1', + call_id: 'call_1', + name: 'exec', + input: 'tools.exec_command({cmd: "ls"})', + }), + rolloutLine('response_item', { + type: 'custom_tool_call_output', + id: 'cto_1', + call_id: 'call_1', + output: [{ type: 'input_text', text: 'src\nREADME.md' }], + }), + // reasoning — ignorado + rolloutLine('response_item', { + type: 'reasoning', + encrypted_content: 'deadbeef', + }), + // turn aborted sintético — ignorado + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'interrupted' }], + }), + // resposta final + rolloutLine('response_item', { + type: 'message', + id: 'msg_def456', + role: 'assistant', + content: [{ type: 'output_text', text: 'bug corrigido' }], + }), + ] +} + +function compactedRolloutLines(sessionId: string): string[] { + return [ + rolloutLine('session_meta', { + session_id: sessionId, + id: sessionId, + cwd: PROJECT_CWD, + cli_version: '0.155.1', + }), + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'prompt antigo pré-compact' }], + }), + rolloutLine('response_item', { + type: 'message', + id: 'msg_old', + role: 'assistant', + content: [{ type: 'output_text', text: 'resposta antiga' }], + }), + rolloutLine('compacted', { + replacement_history: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'resumo pós-compact do trabalho' }], + }, + { + type: 'message', + id: 'msg_new', + role: 'assistant', + content: [{ type: 'output_text', text: 'continuando de onde parei' }], + }, + ], + }), + rolloutLine('response_item', { + type: 'message', + id: 'msg_final', + role: 'assistant', + content: [{ type: 'output_text', text: 'última resposta pós-compact' }], + }), + ] +} + +describe('codexSessionImport', () => { + let codexDir: string + let projectsDir: string + let originalCodexDir: string | undefined + let originalProjectsDir: string | undefined + + beforeAll(() => { + const root = mkdtempSync(join(tmpdir(), 'codex-import-test-')) + codexDir = join(root, 'codex-sessions') + projectsDir = join(root, 'projects') + mkdirSync(join(codexDir, '2026', '09', '22'), { recursive: true }) + // index de nomes do Codex no pai do diretório de sessões (como ~/.codex) + writeFileSync( + join(root, 'session_index.jsonl'), + [ + JSON.stringify({ + id: '01a0c59e-5c1e-73c3-b2ec-b7aa1d785895', + thread_name: 'Destaque usuários fora do fair-use', + }), + JSON.stringify({ + id: '01a0ca5a-1730-7813-a5cb-04967a29fd99', + thread_name: 'Adicionar gráficos ao modal de custo', + }), + ].join('\n') + '\n', + ) + originalCodexDir = process.env.VERBOO_CODEX_SESSIONS_DIR + originalProjectsDir = process.env.VERBOO_PROJECTS_DIR + process.env.VERBOO_CODEX_SESSIONS_DIR = codexDir + process.env.VERBOO_PROJECTS_DIR = projectsDir + }) + + afterAll(() => { + if (originalCodexDir === undefined) { + delete process.env.VERBOO_CODEX_SESSIONS_DIR + } else { + process.env.VERBOO_CODEX_SESSIONS_DIR = originalCodexDir + } + if (originalProjectsDir === undefined) { + delete process.env.VERBOO_PROJECTS_DIR + } else { + process.env.VERBOO_PROJECTS_DIR = originalProjectsDir + } + rmSync(join(codexDir, '..'), { recursive: true, force: true }) + }) + + test('conversão completa: metadados, mensagens, tool pairing e cadeia parentUuid', async () => { + const rolloutPath = join( + codexDir, + '2026', + '09', + '22', + `rollout-2026-09-22T15-21-36-${SESSION_ID}.jsonl`, + ) + writeRollout(rolloutPath, baseRolloutLines()) + + const targetPath = await convertCodexRollout(rolloutPath) + expect(targetPath).toBe( + join(projectsDir, '-home-user-proj-x', `${SESSION_ID}.jsonl`), + ) + expect(existsSync(targetPath)).toBe(true) + + const entries = readFileSync(targetPath, 'utf8') + .split('\n') + .filter(Boolean) + .map(l => JSON.parse(l)) + + // 1ª linha: mode + expect(entries[0]).toMatchObject({ + type: 'mode', + mode: 'normal', + sessionId: SESSION_ID, + }) + // 2ª linha: custom-title com badge [codex] e o primeiro prompt real + expect(entries[1]).toMatchObject({ + type: 'custom-title', + sessionId: SESSION_ID, + customTitle: '[codex] arruma o bug do checkout', + }) + + const messages = entries.filter( + e => e.type === 'user' || e.type === 'assistant', + ) + // user prompt + assistant + tool_use + tool_result + assistant final = 5 + // (wrappers, developer e reasoning são descartados) + expect(messages).toHaveLength(5) + + // cadeia parentUuid coerente + let parent: string | null = null + for (const msg of messages) { + expect(msg.parentUuid).toBe(parent) + parent = msg.uuid + } + + // user prompt real + expect(messages[0].message.content).toBe('arruma o bug do checkout') + // assistant texto + expect(messages[1].message.content[0]).toMatchObject({ + type: 'text', + text: 'vou investigar o checkout', + }) + // tool_use + expect(messages[2].message.content[0]).toMatchObject({ + type: 'tool_use', + id: 'call_1', + name: 'exec', + input: { command: 'tools.exec_command({cmd: "ls"})' }, + }) + // tool_result pareado + expect(messages[3].message.content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call_1', + content: 'src\nREADME.md', + }) + // última resposta + expect(messages[4].message.content[0].text).toBe('bug corrigido') + + // campos de sessão presentes + for (const msg of messages) { + expect(msg.sessionId).toBe(SESSION_ID) + expect(msg.cwd).toBe(PROJECT_CWD) + expect(msg.version).toBe('codex-0.155.1') + expect(msg.isSidechain).toBe(false) + expect(typeof msg.timestamp).toBe('string') + } + }) + + test('conversão é idempotente — não reescreve sessão já convertida', async () => { + const rolloutPath = join( + codexDir, + '2026', + '09', + '22', + `rollout-2026-09-22T15-21-36-${SESSION_ID}.jsonl`, + ) + writeRollout(rolloutPath, baseRolloutLines()) + const first = await convertCodexRollout(rolloutPath) + const contentBefore = readFileSync(first, 'utf8') + + // muda o rollout depois — a sessão convertida não pode ser sobrescrita + writeRollout(rolloutPath, [ + ...baseRolloutLines(), + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'novo prompt pós-conversão' }], + }), + ]) + const second = await convertCodexRollout(rolloutPath) + expect(second).toBe(first) + expect(readFileSync(second, 'utf8')).toBe(contentBefore) + }) + + test('compacted descarta histórico anterior e usa replacement_history', async () => { + const compactedId = '01a0c59e-5c1e-73c3-b2ec-b7aa1d785895' + const rolloutPath = join( + codexDir, + '2026', + '09', + '22', + `rollout-2026-09-22T16-00-00-${compactedId}.jsonl`, + ) + writeRollout(rolloutPath, compactedRolloutLines(compactedId)) + + const targetPath = await convertCodexRollout(rolloutPath) + const messages = readFileSync(targetPath, 'utf8') + .split('\n') + .filter(Boolean) + .map(l => JSON.parse(l)) + .filter(e => e.type === 'user' || e.type === 'assistant') + + // replacement_history (user + assistant) + assistant final = 3 + expect(messages).toHaveLength(3) + expect(messages[0].message.content).toBe('resumo pós-compact do trabalho') + expect(messages[2].message.content[0].text).toBe('última resposta pós-compact') + + // título usa o thread_name do session_index.jsonl (não o prompt) + const titleEntry = readFileSync(targetPath, 'utf8') + .split('\n') + .filter(Boolean) + .map(l => JSON.parse(l)) + .find(e => e.type === 'custom-title') + expect(titleEntry.customTitle).toBe( + '[codex] Destaque usuários fora do fair-use', + ) + }) + + test('listCodexRollouts lista rollouts com cwd e sessionId', async () => { + const rollouts = await listCodexRollouts() + const paths = rollouts.map(r => r.path) + expect(paths.some(p => p.endsWith(`${SESSION_ID}.jsonl`))).toBe(true) + for (const r of rollouts) { + expect(r.cwd).toBe(PROJECT_CWD) + expect(typeof r.sessionId).toBe('string') + expect(r.modified.getTime()).toBeGreaterThan(0) + } + }) + + test('readCodexFirstPrompt extrai o primeiro prompt real', async () => { + const rolloutPath = join( + codexDir, + '2026', + '09', + '22', + `rollout-2026-09-22T15-21-36-${SESSION_ID}.jsonl`, + ) + const prompt = await readCodexFirstPrompt(rolloutPath) + expect(prompt).toBe('arruma o bug do checkout') + }) + + test('getCodexLogOptions injeta lite logs e pula já-convertidas', async () => { + // o rollout base já foi convertido nos testes anteriores → não deve + // aparecer; o compactado também. Cria um rollout novo. + const freshId = '01a0ca5a-1730-7813-a5cb-04967a29fd99' + const freshPath = join( + codexDir, + '2026', + '09', + '22', + `rollout-2026-09-22T17-00-00-${freshId}.jsonl`, + ) + writeRollout(freshPath, [ + rolloutLine('session_meta', { + session_id: freshId, + id: freshId, + cwd: PROJECT_CWD, + cli_version: '0.155.1', + }), + rolloutLine('response_item', { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'prompt do rollout fresco' }], + }), + ]) + + const logs = await getCodexLogOptions(new Set([PROJECT_CWD])) + const fresh = logs.find(l => l.sessionId === freshId) + expect(fresh).toBeDefined() + expect(fresh?.importedFrom).toBe('codex') + expect(fresh?.isLite).toBe(true) + expect(fresh?.fullPath).toBe(freshPath) + expect(fresh?.projectPath).toBe(PROJECT_CWD) + expect(fresh?.messages).toHaveLength(0) + // firstPrompt vem do thread_name do index (não do prompt do rollout) + expect(fresh?.firstPrompt).toBe('Adicionar gráficos ao modal de custo') + + // já convertida não aparece + expect(logs.find(l => l.sessionId === SESSION_ID)).toBeUndefined() + + // filtro por cwd estranho → nada + const other = await getCodexLogOptions(new Set(['/other/dir'])) + expect(other).toHaveLength(0) + }) +}) diff --git a/src/utils/codexSessionImport.ts b/src/utils/codexSessionImport.ts new file mode 100644 index 0000000000..2d155fba13 --- /dev/null +++ b/src/utils/codexSessionImport.ts @@ -0,0 +1,537 @@ +import { randomUUID } from 'crypto' +import { existsSync } from 'fs' +import { mkdir, open as fsOpen, readdir, readFile, stat, writeFile } from 'fs/promises' +import { homedir } from 'os' +import { dirname, join } from 'path' +import type { LogOption } from '../types/logs.js' +import { getProjectDir } from './sessionStoragePortable.js' + +/** + * Import de sessões do Codex CLI (~/.codex/sessions) para o formato de + * transcript do Verboo, habilitando /resume cross-CLI. + * + * O rollout do Codex é append-only com eventos de tipos distintos + * (session_meta, response_item, event_msg, token_usage_record, world_state, + * turn_context, compacted). A conversão mapeia: + * + * response_item message (role=user) → user message (texto) + * response_item message (role=assistant) → assistant message (texto) + * response_item custom_tool_call/function_call → assistant tool_use + * response_item *_output → user tool_result + * compacted → descarta o histórico anterior + * e recomeça de + * replacement_history (mesma + * semântica do Codex: a cauda + * do arquivo é o contexto atual) + * reasoning / event_msg / token_usage_record / + * world_state / turn_context → descartados + * + * Claude Code já é interop por design (getProjectsDir aponta para + * ~/.claude/projects), então apenas o Codex precisa de conversão. + * + * Override do diretório de origem via VERBOO_CODEX_SESSIONS_DIR (testes). + */ + +export type CodexRolloutInfo = { + path: string + sessionId: string + cwd: string + startedAt: Date + modified: Date + size: number +} + +// Mensagens sintéticas/instrução do Codex que não são prompts do usuário. +const CODEX_WRAPPER_PREFIXES = [ + '', + '', + '', + '', +] + +const FIRST_LINE_READ_BYTES = 8192 +const FIRST_PROMPT_MAX_BYTES = 512 * 1024 +const FIRST_PROMPT_CHUNK_BYTES = 64 * 1024 + +export function getCodexSessionsDir(): string { + return ( + process.env.VERBOO_CODEX_SESSIONS_DIR ?? + join(homedir(), '.codex', 'sessions') + ) +} + +/** + * Nomes de sessão do Codex (~/.codex/session_index.jsonl, campo thread_name). + * São os títulos que o `codex resume` mostra — muito mais legíveis que o + * primeiro prompt. Deriva o caminho do pai do diretório de sessões para + * respeitar VERBOO_CODEX_SESSIONS_DIR em testes. + */ +export async function readCodexSessionNames(): Promise> { + const indexPath = join(dirname(getCodexSessionsDir()), 'session_index.jsonl') + const names = new Map() + try { + const content = await readFile(indexPath, 'utf8') + for (const line of content.split('\n')) { + const parsed = parseJsonLine(line) + const id = parsed?.id + const name = parsed?.thread_name + if (typeof id === 'string' && typeof name === 'string' && name) { + names.set(id, name) + } + } + } catch { + // sem index — fallback para o primeiro prompt + } + return names +} + +function isWrapperText(text: string): boolean { + return CODEX_WRAPPER_PREFIXES.some(prefix => text.startsWith(prefix)) +} + +function joinContentText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map(part => + typeof part === 'string' + ? part + : typeof part?.text === 'string' + ? part.text + : '', + ) + .filter(Boolean) + .join('\n') +} + +function parseJsonLine(line: string): any | undefined { + if (!line) return undefined + try { + return JSON.parse(line) + } catch { + return undefined + } +} + +async function readFirstLine(path: string): Promise { + const fh = await fsOpen(path, 'r') + try { + // session_meta pode exceder 8KB (base_instructions embute o system + // prompt) — lê em chunks até achar a quebra de linha. + const chunks: Buffer[] = [] + let offset = 0 + const chunk = Buffer.alloc(FIRST_LINE_READ_BYTES) + while (offset < FIRST_PROMPT_MAX_BYTES) { + const { bytesRead } = await fh.read(chunk, 0, chunk.length, offset) + if (bytesRead === 0) break + const slice = Buffer.from(chunk.subarray(0, bytesRead)) + chunks.push(slice) + if (slice.includes(0x0a)) { + const full = Buffer.concat(chunks) + const nl = full.indexOf(0x0a) + return full.toString('utf8', 0, nl === -1 ? full.length : nl) + } + offset += bytesRead + } + return Buffer.concat(chunks).toString('utf8') + } finally { + await fh.close() + } +} + +/** + * Lê o session_meta (primeira linha) de um rollout. + * Retorna undefined se o arquivo não parecer um rollout válido. + */ +async function readSessionMeta( + path: string, +): Promise< + | { + sessionId: string + cwd: string + cliVersion: string + timestamp: string + } + | undefined +> { + const firstLine = await readFirstLine(path) + const parsed = parseJsonLine(firstLine) + if (parsed?.type !== 'session_meta') return undefined + const payload = parsed.payload ?? {} + const sessionId = payload.id ?? payload.session_id + const cwd = payload.cwd + if (typeof sessionId !== 'string' || typeof cwd !== 'string') { + return undefined + } + return { + sessionId, + cwd, + cliVersion: + typeof payload.cli_version === 'string' ? payload.cli_version : 'unknown', + timestamp: + typeof payload.timestamp === 'string' + ? payload.timestamp + : new Date().toISOString(), + } +} + +/** Varre ~/.codex/sessions recursivamente (YYYY/MM/DD) por rollouts. */ +export async function listCodexRollouts(): Promise { + const root = getCodexSessionsDir() + const rolloutPaths: string[] = [] + + async function walk(dir: string, depth: number): Promise { + if (depth > 4) return + let dirents + try { + dirents = await readdir(dir, { withFileTypes: true }) + } catch { + return + } + for (const dirent of dirents) { + const fullPath = join(dir, dirent.name) + if (dirent.isDirectory()) { + await walk(fullPath, depth + 1) + } else if ( + dirent.isFile() && + dirent.name.startsWith('rollout-') && + dirent.name.endsWith('.jsonl') + ) { + rolloutPaths.push(fullPath) + } + } + } + + await walk(root, 0) + + const rollouts: CodexRolloutInfo[] = [] + for (const path of rolloutPaths) { + const meta = await readSessionMeta(path) + if (!meta) continue + let fileStat + try { + fileStat = await stat(path) + } catch { + continue + } + rollouts.push({ + path, + sessionId: meta.sessionId, + cwd: meta.cwd, + startedAt: new Date(meta.timestamp), + modified: new Date(fileStat.mtimeMs), + size: fileStat.size, + }) + } + rollouts.sort((a, b) => b.modified.getTime() - a.modified.getTime()) + return rollouts +} + +function extractUserPromptFromLine(line: string): string | undefined { + const parsed = parseJsonLine(line) + if (parsed?.type !== 'response_item') return undefined + const payload = parsed.payload ?? {} + if (payload.type !== 'message' || payload.role !== 'user') return undefined + const text = joinContentText(payload.content) + if (!text || isWrapperText(text)) return undefined + return text +} + +/** + * Extrai o primeiro prompt real do usuário (para o título no /resume). + * Leitura em chunks para não materializar rollouts grandes. + */ +export async function readCodexFirstPrompt(path: string): Promise { + const fh = await fsOpen(path, 'r') + try { + let carry = '' + let offset = 0 + const chunk = Buffer.alloc(FIRST_PROMPT_CHUNK_BYTES) + while (offset < FIRST_PROMPT_MAX_BYTES) { + const { bytesRead } = await fh.read(chunk, 0, chunk.length, offset) + if (bytesRead === 0) break + offset += bytesRead + carry += chunk.toString('utf8', 0, bytesRead) + const lines = carry.split('\n') + carry = lines.pop() ?? '' + for (const line of lines) { + const prompt = extractUserPromptFromLine(line) + if (prompt !== undefined) return prompt + } + } + return '' + } finally { + await fh.close() + } +} + +type RolloutMeta = { + sessionId: string + cwd: string + cliVersion: string +} + +/** + * Converte as linhas de um rollout em entradas de transcript Claude + * (passada única; `compacted` reseta o histórico acumulado no ponto). + */ +function convertRolloutLines( + lines: string[], + meta: RolloutMeta, +): { entries: Array>; firstPrompt: string } { + const metaEntries: Array> = [ + { type: 'mode', mode: 'normal', sessionId: meta.sessionId }, + ] + let messageEntries: Array> = [] + let parentUuid: string | null = null + let firstPrompt = '' + let lastModel = 'codex-import' + + const baseFields = (timestamp: string) => ({ + isSidechain: false, + userType: 'external', + cwd: meta.cwd, + sessionId: meta.sessionId, + version: `codex-${meta.cliVersion}`, + timestamp, + }) + + const emit = (entry: Record, timestamp: string) => { + const full = { + ...baseFields(timestamp), + ...entry, + uuid: randomUUID(), + parentUuid, + } + parentUuid = full.uuid as string + messageEntries.push(full) + } + + const processMessageItem = (item: { + role?: string + content?: unknown + id?: string + }): void => { + const text = joinContentText(item.content) + if (!text) return + if (item.role === 'user') { + if (isWrapperText(text)) return + if (!firstPrompt) firstPrompt = text + emit({ type: 'user', message: { role: 'user', content: text } }, NOW()) + } else if (item.role === 'assistant') { + emit( + { + type: 'assistant', + message: { + ...(item.id ? { id: item.id } : {}), + type: 'message', + role: 'assistant', + model: lastModel, + content: [{ type: 'text', text }], + }, + }, + NOW(), + ) + } + } + + const processToolCall = (payload: Record): void => { + let input: Record + if (payload.type === 'function_call') { + try { + input = JSON.parse(payload.arguments as string) as Record< + string, + unknown + > + } catch { + input = { arguments: payload.arguments } + } + } else { + // custom_tool_call: input é o código-fonte JS do comando + input = + typeof payload.input === 'string' + ? { command: payload.input } + : ((payload.input as Record) ?? {}) + } + emit( + { + type: 'assistant', + message: { + ...(payload.id ? { id: payload.id as string } : {}), + type: 'message', + role: 'assistant', + model: lastModel, + content: [ + { + type: 'tool_use', + id: (payload.call_id as string) ?? `call_${randomUUID()}`, + name: (payload.name as string) ?? 'codex_tool', + input, + }, + ], + }, + }, + NOW(), + ) + } + + const processToolOutput = (payload: Record): void => { + emit( + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: + (payload.call_id as string) ?? `call_${randomUUID()}`, + content: joinContentText(payload.output), + }, + ], + }, + }, + NOW(), + ) + } + + // Timestamp corrente da linha sendo processada (passada única). + let currentTimestamp = new Date().toISOString() + const NOW = () => currentTimestamp + + for (const line of lines) { + const parsed = parseJsonLine(line) + if (!parsed) continue + if (typeof parsed.timestamp === 'string') { + currentTimestamp = parsed.timestamp + } + + if (parsed.type === 'event_msg') { + const model = parsed.payload?.thread_settings?.model + if (typeof model === 'string') lastModel = model + continue + } + if (parsed.type === 'world_state') { + const model = parsed.payload?.state?.collaboration_mode?.model + if (typeof model === 'string') lastModel = model + continue + } + if (parsed.type === 'compacted') { + // O Codex substituiu o contexto: descarta o histórico convertido até + // aqui e recomeça de replacement_history. + messageEntries = [] + parentUuid = null + const replacement: unknown[] = parsed.payload?.replacement_history ?? [] + for (const item of replacement) { + if ((item as { type?: string })?.type === 'message') { + processMessageItem(item as { role?: string; content?: unknown; id?: string }) + } + } + continue + } + if (parsed.type !== 'response_item') continue + + const payload = parsed.payload ?? {} + switch (payload.type) { + case 'message': + processMessageItem(payload) + break + case 'custom_tool_call': + case 'function_call': + processToolCall(payload) + break + case 'custom_tool_call_output': + case 'function_call_output': + processToolOutput(payload) + break + default: + // reasoning (criptografado), web_search_call, etc. — descartados + break + } + } + + const entries = [...metaEntries, ...messageEntries] + return { entries, firstPrompt } +} + +/** + * Converte um rollout do Codex em um arquivo de sessão Claude em + * ~/.claude/projects//.jsonl. + * + * Idempotente: se o destino já existe, retorna o caminho sem reescrever + * (nunca sobrescreve uma sessão que já foi continuada no Verboo). + */ +export async function convertCodexRollout( + rolloutPath: string, +): Promise { + const meta = await readSessionMeta(rolloutPath) + if (!meta) { + throw new Error(`Não é um rollout válido do Codex: ${rolloutPath}`) + } + const targetDir = getProjectDir(meta.cwd) + const targetPath = join(targetDir, `${meta.sessionId}.jsonl`) + if (existsSync(targetPath)) { + return targetPath + } + + const content = await readFile(rolloutPath, 'utf8') + const lines = content.split('\n') + const { entries, firstPrompt } = convertRolloutLines(lines, meta) + + // Título: nome da sessão no Codex (session_index.jsonl) > primeiro prompt + const sessionNames = await readCodexSessionNames() + const title = sessionNames.get(meta.sessionId) ?? firstPrompt + if (title) { + entries.splice(1, 0, { + type: 'custom-title', + sessionId: meta.sessionId, + customTitle: `[codex] ${title.slice(0, 100)}`, + }) + } + + await mkdir(targetDir, { recursive: true }) + await writeFile( + targetPath, + entries.map(entry => JSON.stringify(entry)).join('\n') + '\n', + { mode: 0o600 }, + ) + return targetPath +} + +/** + * LogOptions (lite) dos rollouts do Codex para injetar nos loaders do + * /resume. Rollouts já convertidos são pulados — a sessão nativa + * (convertida) aparece pelo scan normal de ~/.claude/projects. + */ +export async function getCodexLogOptions( + cwdFilter?: Set, +): Promise { + const rollouts = await listCodexRollouts() + const sessionNames = await readCodexSessionNames() + const logs: LogOption[] = [] + for (const rollout of rollouts) { + if (cwdFilter && !cwdFilter.has(rollout.cwd)) continue + const convertedPath = join( + getProjectDir(rollout.cwd), + `${rollout.sessionId}.jsonl`, + ) + if (existsSync(convertedPath)) continue + logs.push({ + date: rollout.modified.toISOString(), + messages: [], + isLite: true, + fullPath: rollout.path, + value: 0, + created: rollout.startedAt, + modified: rollout.modified, + firstPrompt: sessionNames.get(rollout.sessionId) ?? '', + messageCount: 0, + fileSize: rollout.size, + isSidechain: false, + sessionId: rollout.sessionId, + projectPath: rollout.cwd, + importedFrom: 'codex', + }) + } + return logs +} diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 1fb3179a9e..08beb10e97 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -69,6 +69,12 @@ import type { QueueOperationMessage } from '../types/messageQueueTypes.js' import { uniq } from './array.js' import { registerCleanup } from './cleanupRegistry.js' import { updateSessionName } from './concurrentSessions.js' +import { + convertCodexRollout, + getCodexLogOptions, + readCodexFirstPrompt, + readCodexSessionNames, +} from './codexSessionImport.js' import { getCwd } from './cwd.js' import { logForDebugging } from './debug.js' import { logForDiagnosticsNoPII } from './diagLogs.js' @@ -2901,6 +2907,8 @@ async function trackSessionBranchingAnalytics( export async function fetchLogs(limit?: number): Promise { const projectDir = getProjectDir(getOriginalCwd()) const logs = await getSessionFilesLite(projectDir, limit, getOriginalCwd()) + // Sessões do Codex CLI (ainda não convertidas) para --continue cross-CLI + logs.push(...(await getCodexLogOptions(new Set([getOriginalCwd()])))) await trackSessionBranchingAnalytics(logs) @@ -3294,8 +3302,21 @@ export async function loadFullLog(log: LogOption): Promise { return log } - // Use the fullPath from the index entry directly - const sessionFile = log.fullPath + // Rollout do Codex: converte para o formato nativo antes de carregar. + // A conversão é idempotente — sessões já convertidas apontam direto + // para o arquivo nativo em ~/.claude/projects. + let sessionFile = log.fullPath + let resolvedLog = log + if (log.importedFrom === 'codex' && log.fullPath) { + try { + const convertedPath = await convertCodexRollout(log.fullPath) + sessionFile = convertedPath + resolvedLog = { ...log, fullPath: convertedPath, importedFrom: undefined } + } catch (error) { + logError(error) + return log + } + } if (!sessionFile) { return log } @@ -3323,7 +3344,7 @@ export async function loadFullLog(log: LogOption): Promise { } = await loadTranscriptFile(sessionFile) if (messages.size === 0) { - return log + return resolvedLog } // Find the most recent user/assistant leaf message from the transcript @@ -3334,7 +3355,7 @@ export async function loadFullLog(log: LogOption): Promise { (msg.type === 'user' || msg.type === 'assistant'), ) if (!mostRecentLeaf) { - return log + return resolvedLog } // Build the conversation chain from this leaf @@ -3343,7 +3364,7 @@ export async function loadFullLog(log: LogOption): Promise { // metadata entries (custom-title etc.) are keyed by the current session. const sessionId = mostRecentLeaf.sessionId as UUID | undefined return { - ...log, + ...resolvedLog, messages: removeExtraFields(transcript), firstPrompt: extractFirstPrompt(transcript), messageCount: countVisibleMessages(transcript), @@ -3393,7 +3414,7 @@ export async function loadFullLog(log: LogOption): Promise { } } catch { // If loading fails, return the original log - return log + return resolvedLog } } @@ -4397,6 +4418,9 @@ export async function loadAllProjectsMessageLogsProgressive( } } + // Sessões do Codex CLI (ainda não convertidas) — visão "all projects" + rawLogs.push(...(await getCodexLogOptions())) + // Deduplicate — same session can appear in multiple project dirs const sorted = deduplicateLogsBySessionId(rawLogs) @@ -4489,6 +4513,10 @@ async function getStatOnlyLogsForWorktrees( ...(await getSessionFilesLite(additionalProjectDir, undefined, cwd)), ) } + // Sessões do Codex CLI do mesmo cwd (ainda não convertidas) + allLogs.push( + ...(await getCodexLogOptions(new Set([...worktreePaths, cwd]))), + ) return deduplicateLogsBySessionId(allLogs) } @@ -4549,6 +4577,13 @@ async function getStatOnlyLogsForWorktrees( await scanSourceDir(additionalDir) } + // Sessões do Codex CLI dos worktrees (ainda não convertidas) + allLogs.push( + ...(await getCodexLogOptions( + new Set([...worktreePaths, getOriginalCwd()]), + )), + ) + // Deduplicate by sessionId — the same session can appear in multiple // worktree project dirs. Keep the entry with the newest modified time. return deduplicateLogsBySessionId(allLogs) @@ -5402,6 +5437,20 @@ async function enrichLog( ): Promise { if (!log.isLite || !log.fullPath) return log + // Rollouts do Codex têm formato próprio — readLiteMetadata não se aplica. + // Título: nome da sessão no Codex (session_index.jsonl) > primeiro prompt. + if (log.importedFrom === 'codex') { + const sessionNames = await readCodexSessionNames() + const name = log.sessionId ? sessionNames.get(log.sessionId) : undefined + const firstPrompt = + name ?? (await readCodexFirstPrompt(log.fullPath)) + return { + ...log, + isLite: false, + firstPrompt: firstPrompt || '(codex)', + } + } + const meta = await readLiteMetadata(log.fullPath, log.fileSize ?? 0, readBuf) const enriched: LogOption = {