diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index f77a7728..a425421f 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -95,6 +95,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.summary !== undefined) preserved.summary = metadata.summary if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId + if (metadata.codexSourceSessionId !== undefined) preserved.codexSourceSessionId = metadata.codexSourceSessionId if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId @@ -102,6 +103,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId + if (metadata.preferredPermissionMode !== undefined) preserved.preferredPermissionMode = metadata.preferredPermissionMode if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts index a28b6931..f49a9460 100644 --- a/cli/src/api/apiMachine.test.ts +++ b/cli/src/api/apiMachine.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' @@ -92,6 +92,35 @@ async function callCursorChatStoreStatus( return JSON.parse(raw) as unknown } +async function callListCodexSessions(client: ApiMachineClient, machineId: string, params: { cwd?: string | null; sessionIds?: string[] }): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:listCodexSessions`, + params: JSON.stringify(params) + }) + return JSON.parse(raw) as unknown +} + +async function callArchiveCodexSession(client: ApiMachineClient, machineId: string, sessionId: string): Promise { + const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise } }).rpcHandlerManager + const raw = await manager.handleRequest({ + method: `${machineId}:archiveCodexSession`, + params: JSON.stringify({ sessionId }) + }) + return JSON.parse(raw) as unknown +} + +function writeCodexTranscript(codexHome: string, fileName: string, payload: Record, userText: string): string { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '29') + mkdirSync(sessionDir, { recursive: true }) + const file = join(sessionDir, fileName) + writeFileSync(file, [ + JSON.stringify({ type: 'session_meta', payload }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: userText }] } }) + ].join('\n')) + return file +} + describe('ApiMachineClient cursor-chat-store-status handler', () => { beforeEach(() => { inspectCursorChatStoreMock.mockReset() @@ -300,6 +329,100 @@ describe('ApiMachineClient listGrokModelsForCwd handler', () => { }) }) +describe('ApiMachineClient Codex transcript handlers', () => { + const originalCodexHome = process.env.CODEX_HOME + let workspaceRoot: string + let outsideRoot: string + let codexHome: string + + beforeEach(() => { + ioMock.mockReset() + listOpencodeModelsForCwdMock.mockReset() + workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-codex-allowed-')) + outsideRoot = mkdtempSync(join(tmpdir(), 'hapi-codex-outside-')) + codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-')) + process.env.CODEX_HOME = codexHome + }) + + afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = originalCodexHome + rmSync(workspaceRoot, { recursive: true, force: true }) + rmSync(outsideRoot, { recursive: true, force: true }) + rmSync(codexHome, { recursive: true, force: true }) + }) + + it('filters listed Codex sessions to workspace roots', async () => { + writeCodexTranscript(codexHome, 'allowed.jsonl', { + id: 'allowed-session-id', + cwd: workspaceRoot + }, 'allowed prompt') + writeCodexTranscript(codexHome, 'outside.jsonl', { + id: 'outside-session-id', + cwd: outsideRoot + }, 'outside prompt') + + const machine = makeMachine('codex-machine-1') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + + try { + const result = await callListCodexSessions(client, machine.id, {}) + + expect(result).toMatchObject({ success: true }) + const sessions = (result as { sessions: Array<{ id: string }> }).sessions + expect(sessions.map((session) => session.id)).toEqual(['allowed-session-id']) + } finally { + client.shutdown() + } + }) + + it('filters import-by-sessionId Codex sessions to workspace roots before returning message bodies', async () => { + writeCodexTranscript(codexHome, 'allowed.jsonl', { + id: 'allowed-session-id', + cwd: workspaceRoot + }, 'allowed prompt') + writeCodexTranscript(codexHome, 'outside.jsonl', { + id: 'outside-session-id', + cwd: outsideRoot + }, 'outside prompt') + + const machine = makeMachine('codex-machine-2') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + + try { + const result = await callListCodexSessions(client, machine.id, { + sessionIds: ['allowed-session-id', 'outside-session-id'] + }) + + expect(result).toMatchObject({ success: true }) + const sessions = (result as { sessions: Array<{ id: string; messages?: unknown[] }> }).sessions + expect(sessions.map((session) => session.id)).toEqual(['allowed-session-id']) + expect(sessions[0]?.messages).toHaveLength(1) + } finally { + client.shutdown() + } + }) + + it('rejects archive for Codex sessions outside workspace roots', async () => { + const outsideFile = writeCodexTranscript(codexHome, 'outside.jsonl', { + id: 'outside-session-id', + cwd: outsideRoot + }, 'outside prompt') + + const machine = makeMachine('codex-machine-3') + const client = new ApiMachineClient('cli-token', machine, [workspaceRoot]) + + try { + const result = await callArchiveCodexSession(client, machine.id, 'outside-session-id') + + expect(result).toEqual({ success: false, error: 'Codex session is outside workspace roots' }) + expect(existsSync(outsideFile)).toBe(true) + } finally { + client.shutdown() + } + }) +}) + describe('ApiMachineClient keepAlive lifecycle', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 6da0a37e..6247c0b7 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -9,7 +9,15 @@ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } import { logger } from '@/ui/logger' import { configuration } from '@/configuration' import type { ClientToServerEvents, ServerToClientEvents, Update, UpdateMachineBody } from '@hapi/protocol' -import type { MachineDirectoryEntry, MachineListDirectoryResponse, PathExistsResponse } from '@hapi/protocol/apiTypes' +import { + ArchiveCodexSessionRpcRequestSchema, + ListCodexSessionsRpcRequestSchema, + type ArchiveCodexSessionRpcResponse, + type ListCodexSessionsRpcResponse, + type MachineDirectoryEntry, + type MachineListDirectoryResponse, + type PathExistsResponse +} from '@hapi/protocol/apiTypes' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import type { RunnerState, Machine, MachineMetadata } from './types' import { RunnerStateSchema, MachineMetadataSchema } from './types' @@ -29,6 +37,7 @@ import { } from '../modules/common/grokModels' import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' import { applyVersionedAck } from './versionedUpdate' +import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from '../modules/common/codexSessions' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' import { collectMachineHealth } from '@/utils/machineHealth' import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus' @@ -257,6 +266,54 @@ export class ApiMachineClient { return await listGrokModelsForCwd(resolvedCwd) } ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ListCodexSessions, + async (params) => { + const parsed = ListCodexSessionsRpcRequestSchema.safeParse(params) + if (!parsed.success) return { success: false, error: 'Invalid Codex sessions request' } + const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : '' + if (rawCwd) { + const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd) + if (!this.isWithinWorkspaceRoots(resolvedCwd)) { + return { success: false, error: 'Path is outside workspace roots' } + } + } + const requestedIds = parsed.data.sessionIds + ? new Set(parsed.data.sessionIds) + : null + const allSessions = requestedIds + ? listLocalCodexSessionsWithMessagesByIds(requestedIds) + : listLocalCodexSessionSummaries() + const sessions = [] + for (const session of allSessions) { + if (await this.isCodexSessionWithinWorkspaceRoots(session)) { + sessions.push(session) + } + } + return { success: true, sessions } + } + ) + + this.rpcHandlerManager.registerHandler( + RPC_METHODS.ArchiveCodexSession, + async (params) => { + const parsed = ArchiveCodexSessionRpcRequestSchema.safeParse(params) + if (!parsed.success) return { success: false, error: 'Invalid Codex archive request' } + const sessionId = parsed.data.sessionId.trim() + return await archiveLocalCodexSession(sessionId, { + canArchive: (session) => this.isCodexSessionWithinWorkspaceRoots(session) + }) + } + ) + } + + private async isCodexSessionWithinWorkspaceRoots(session: { cwd?: string | null }): Promise { + if (!this.normalizedWorkspaceRoots?.length) return true + const cwd = session.cwd?.trim() + if (!cwd) return false + const resolvedCwd = await this.resolveForWorkspaceCheck(cwd) + return this.isWithinWorkspaceRoots(resolvedCwd) } private isWithinWorkspaceRoots(absolutePath: string): boolean { @@ -300,7 +357,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -314,6 +371,7 @@ export class ApiMachineClient { const result = await spawnSession({ directory, sessionId, + existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index bb06f221..7ba52130 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -127,11 +127,24 @@ export interface ThreadResumeParams { export interface ThreadResumeResponse { thread: { id: string; + turns?: Array<{ items?: ResponseItem[] }>; }; model: string; [key: string]: unknown; } +export interface ThreadForkParams extends Omit { +} + +export interface ThreadForkResponse { + thread: { + id: string; + turns?: Array<{ items?: ResponseItem[] }>; + }; + model?: string; + [key: string]: unknown; +} + export type UserInput = | { type: 'text'; diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 31569e13..e8ebd2e6 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -1,4 +1,5 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { logger } from '@/ui/logger'; import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; @@ -12,6 +13,8 @@ import type { ThreadStartResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadForkParams, + ThreadForkResponse, TurnStartParams, TurnStartResponse, TurnInterruptParams, @@ -72,6 +75,84 @@ function createAbortError(): Error { return error; } +type CodexCommandCandidate = { + command: string; + source: 'desktop' | 'path'; + version: number[] | null; +}; + +function parseCodexVersion(output: string): number[] | null { + const match = /(\d+)\.(\d+)\.(\d+)(?:[-+][^\s]+)?/u.exec(output); + if (!match) return null; + return [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function getCodexVersion(command: string): number[] | null { + try { + const output = execFileSync(command, ['--version'], { + encoding: 'utf8', + timeout: 3_000, + stdio: ['ignore', 'pipe', 'ignore'] + }); + return parseCodexVersion(output); + } catch { + return null; + } +} + +function compareVersion(a: number[] | null, b: number[] | null): number { + if (!a && !b) return 0; + if (a && !b) return 1; + if (!a && b) return -1; + for (let index = 0; index < 3; index += 1) { + const diff = (a?.[index] ?? 0) - (b?.[index] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function resolveCodexAppServerCommand(): string { + if (process.env.HAPI_CODEX_APP_SERVER_BIN) { + return process.env.HAPI_CODEX_APP_SERVER_BIN; + } + + const candidates: CodexCommandCandidate[] = [{ + command: 'codex', + source: 'path', + version: getCodexVersion('codex') + }]; + + if (process.platform === 'darwin') { + const desktopCodex = '/Applications/Codex.app/Contents/Resources/codex'; + if (existsSync(desktopCodex)) { + candidates.push({ + command: desktopCodex, + source: 'desktop', + version: getCodexVersion(desktopCodex) + }); + } + } + + // 中文注释:Codex Desktop 与 npm CLI 都可能写 thread-store;恢复时选择版本更新的 app-server, + // 避免旧 CLI 读取新 rollout 格式失败。版本相同优先 Desktop,和用户看到的 Codex.app 保持一致。 + const best = candidates.sort((left, right) => { + const versionDiff = compareVersion(right.version, left.version); + if (versionDiff !== 0) return versionDiff; + if (left.source === right.source) return 0; + return left.source === 'desktop' ? -1 : 1; + })[0]; + + logger.debug('[CodexAppServer] Resolved codex command', { + selected: best.command, + candidates: candidates.map((candidate) => ({ + command: candidate.command, + source: candidate.source, + version: candidate.version?.join('.') ?? null + })) + }); + return best.command; +} + export class CodexAppServerClient extends JsonLineParser { private process: ChildProcessWithoutNullStreams | null = null; private connected = false; @@ -93,7 +174,9 @@ export class CodexAppServerClient extends JsonLineParser { return; } - this.process = spawn('codex', ['app-server'], { + const codexCommand = resolveCodexAppServerCommand(); + logger.debug(`[CodexAppServer] Starting ${codexCommand} app-server`); + this.process = spawn(codexCommand, ['app-server'], { env: Object.keys(process.env).reduce((acc, key) => { const value = process.env[key]; if (typeof value === 'string') acc[key] = value; @@ -194,6 +277,14 @@ export class CodexAppServerClient extends JsonLineParser { return response as ThreadResumeResponse; } + async forkThread(params: ThreadForkParams, options?: { signal?: AbortSignal }): Promise { + const response = await this.sendRequest('thread/fork', params, { + signal: options?.signal, + timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS + }); + return response as ThreadForkResponse; + } + async startTurn(params: TurnStartParams, options?: { signal?: AbortSignal }): Promise { const response = await this.sendRequest('turn/start', params, { signal: options?.signal, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index bb245c63..ed6e5026 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -1984,7 +1984,7 @@ describe('codexRemoteLauncher', () => { expect(session.sessionId).toBe('thread-old'); expect(sessionEvents).toContainEqual({ type: 'message', - message: 'Task failed: Codex conversation thread-old could not be resumed; no new conversation was created' + message: 'Task failed: Codex conversation thread-old could not be resumed; no new conversation was created. Reason: resume failed' }); expect(session.thinking).toBe(false); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index c5ff2af7..849298f8 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -20,6 +20,7 @@ import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerC import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; +import { extractErrorInfo } from '@/utils/errorUtils'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -80,6 +81,17 @@ const CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR = 'Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' + 'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.'; +function formatCodexResumeError(error: unknown): string { + const info = extractErrorInfo(error); + const message = info.message && info.message !== 'Unknown error' ? info.message : ''; + const record = error && typeof error === 'object' ? error as Record : null; + const name = error instanceof Error && error.name && error.name !== 'Error' ? error.name : ''; + const cause = record?.cause instanceof Error ? record.cause.message : typeof record?.cause === 'string' ? record.cause : ''; + const code = typeof record?.code === 'string' ? record.code : ''; + const parts = [name, code, message, cause].filter((part) => part.trim().length > 0); + return parts.length > 0 ? Array.from(new Set(parts)).join(': ') : 'unknown resume error'; +} + const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [ 'selected model is at capacity', 'codex thread entered systemerror' @@ -3513,10 +3525,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase { while (!this.shouldExit) { logActiveHandles('loop-top'); - if (!pending && (recoveryInFlight || (turnInFlight && session.queue.size() === 0))) { + if (!pending && recoveryInFlight) { await waitForTurnOrRecovery(this.abortController.signal); if (this.abortController.signal.aborted && !this.shouldExit) { - logger.debug('[codex]: Internal wait aborted while turn/recovery was active; continuing'); + logger.debug('[codex]: Internal wait aborted while recovery was active; continuing'); + continue; + } + continue; + } + + if (!pending && turnInFlight && session.queue.size() === 0) { + await waitForTurnOrRecovery(this.abortController.signal); + if (this.abortController.signal.aborted && !this.shouldExit) { + logger.debug('[codex]: Internal wait aborted while turn was active; continuing'); continue; } continue; @@ -3579,20 +3600,34 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (resumeCandidate) { try { - const resumeResponse = await appServerClient.resumeThread({ - threadId: resumeCandidate, - ...threadParams - }, { - signal: this.abortController.signal - }); - const resumeRecord = asRecord(resumeResponse); - const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; - threadId = asString(resumeThread?.id) ?? resumeCandidate; - applyResolvedModel(resumeRecord?.model); - logger.debug(`[Codex] Resumed app-server thread ${threadId}`); + const shouldForkImportedSource = Boolean( + session.sourceSessionId + && resumeCandidate === session.sourceSessionId + ); + const response = shouldForkImportedSource + ? await appServerClient.forkThread({ + threadId: resumeCandidate, + ...threadParams + }, { + signal: this.abortController.signal + }) + : await appServerClient.resumeThread({ + threadId: resumeCandidate, + ...threadParams + }, { + signal: this.abortController.signal + }); + const responseRecord = asRecord(response); + const responseThread = responseRecord ? asRecord(responseRecord.thread) : null; + threadId = asString(responseThread?.id) ?? resumeCandidate; + applyResolvedModel(responseRecord?.model); + logger.debug(shouldForkImportedSource + ? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}` + : `[Codex] Resumed app-server thread ${threadId}`); } catch (error) { - logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary`, error); - const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created`; + const resumeError = formatCodexResumeError(error); + logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary: ${resumeError}`, error); + const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created. Reason: ${resumeError}`; messageBuffer.addMessage(failureMessage, 'status'); session.sendSessionEvent({ type: 'message', message: failureMessage }); pending = null; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index ff60da6b..b05a3fed 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -38,6 +38,7 @@ interface LoopOptions { modelReasoningEffort?: ReasoningEffort; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; + sourceSessionId?: string; replayTranscriptHistoryOnStart?: boolean; onSessionReady?: (session: CodexSession) => void; } @@ -63,6 +64,7 @@ export async function loop(opts: LoopOptions): Promise { model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, collaborationMode: opts.collaborationMode ?? 'default', + sourceSessionId: opts.sourceSessionId, replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false }); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 04b8a9df..fa133e24 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -60,6 +60,9 @@ export async function runCodex(opts: { modelReasoningEffort: opts.modelReasoningEffort }); const { api, session, sessionInfo } = bootstrap; + const codexSourceSessionId = typeof sessionInfo.metadata?.codexSourceSessionId === 'string' + ? sessionInfo.metadata.codexSourceSessionId + : undefined; const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; @@ -79,7 +82,10 @@ export async function runCodex(opts: { // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 const replayTranscriptHistoryOnStart = useLazyBootstrap || Boolean(opts.resumeSessionId && !opts.existingSessionId); - let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + const persistedPermissionMode = sessionInfo.permissionMode ?? sessionInfo.metadata?.preferredPermissionMode; + let currentPermissionMode: PermissionMode = opts.permissionMode + ?? (persistedPermissionMode && isPermissionModeAllowedForFlavor(persistedPermissionMode, 'codex') ? persistedPermissionMode as PermissionMode : undefined) + ?? 'default'; let currentModel = opts.model; let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort; let currentCollaborationMode: EnhancedMode['collaborationMode'] = opts.collaborationMode ?? 'default'; @@ -401,6 +407,7 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + sourceSessionId: codexSourceSessionId, replayTranscriptHistoryOnStart, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 1fdf985f..ae9b133e 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -18,6 +18,7 @@ export class CodexSession extends AgentSessionBase { readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; readonly replayTranscriptHistoryOnStart: boolean; + readonly sourceSessionId?: string; localLaunchFailure: LocalLaunchFailure | null = null; private transcriptPathCallbacks: Array<(path: string) => void> = []; @@ -40,6 +41,7 @@ export class CodexSession extends AgentSessionBase { modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; replayTranscriptHistoryOnStart?: boolean; + sourceSessionId?: string; }) { super({ api: opts.api, @@ -67,6 +69,7 @@ export class CodexSession extends AgentSessionBase { this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.replayTranscriptHistoryOnStart = opts.replayTranscriptHistoryOnStart ?? false; + this.sourceSessionId = opts.sourceSessionId; this.permissionMode = opts.permissionMode; this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index a57cce5c..85e89f6d 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -31,6 +31,7 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + existingSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort serviceTier?: string @@ -51,6 +52,12 @@ export const codexCommand: CommandDefinition = { } if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--existing-session-id') { + const sessionId = commandArgs[++i] + if (!sessionId) { + throw new Error('Missing --existing-session-id value') + } + options.existingSessionId = sessionId } else if (arg === '--permission-mode') { const mode = commandArgs[++i] if (!mode || !(CODEX_PERMISSION_MODES as readonly string[]).includes(mode)) { diff --git a/cli/src/modules/common/codexSessions.test.ts b/cli/src/modules/common/codexSessions.test.ts new file mode 100644 index 00000000..c4188230 --- /dev/null +++ b/cli/src/modules/common/codexSessions.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from './codexSessions' + +describe('archiveLocalCodexSession', () => { + const originalCodexHome = process.env.CODEX_HOME + + afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = originalCodexHome + }) + + it('moves a local codex transcript into archived_sessions preserving relative path', async () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionFile = join(root, 'sessions', '2026', '06', '27', 'rollout-2026-06-27T12-00-00-12345678-1234-1234-1234-123456789abc.jsonl') + mkdirSync(join(root, 'sessions', '2026', '06', '27'), { recursive: true }) + writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload: { id: '12345678-1234-1234-1234-123456789abc', cwd: '/tmp/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }) + ].join('\n')) + + const sessions = listLocalCodexSessionSummaries() + expect(sessions).toHaveLength(1) + expect(sessions[0]?.id).toBe('12345678-1234-1234-1234-123456789abc') + + const result = await archiveLocalCodexSession('12345678-1234-1234-1234-123456789abc') + expect(result.success).toBe(true) + if (!result.success) return + expect(existsSync(sessionFile)).toBe(false) + expect(existsSync(result.archivedPath)).toBe(true) + expect(readFileSync(result.archivedPath, 'utf-8')).toContain('session_meta') + + rmSync(root, { recursive: true, force: true }) + }) + + it('refuses to archive when the caller denies the session', async () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionFile = join(root, 'sessions', '2026', '06', '27', 'outside.jsonl') + mkdirSync(join(root, 'sessions', '2026', '06', '27'), { recursive: true }) + writeFileSync(sessionFile, [ + JSON.stringify({ type: 'session_meta', payload: { id: 'outside-session-id', cwd: '/tmp/outside' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'outside' }] } }) + ].join('\n')) + + const result = await archiveLocalCodexSession('outside-session-id', { canArchive: () => false }) + + expect(result).toEqual({ success: false, error: 'Codex session is outside workspace roots' }) + expect(existsSync(sessionFile)).toBe(true) + + rmSync(root, { recursive: true, force: true }) + }) +}) + +describe('listLocalCodexSessionSummaries', () => { + const originalCodexHome = process.env.CODEX_HOME + + afterEach(() => { + if (originalCodexHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = originalCodexHome + }) + + it('parses original and fork metadata from session_meta', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionsDir = join(root, 'sessions', '2026', '06', '27') + mkdirSync(sessionsDir, { recursive: true }) + + writeFileSync(join(sessionsDir, 'original.jsonl'), [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'original-session-id', + cwd: '/tmp/project', + originator: 'Codex Desktop', + cli_version: '0.142.2', + source: 'vscode', + thread_source: 'user' + } + }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }) + ].join('\n')) + + writeFileSync(join(sessionsDir, 'fork.jsonl'), [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'fork-session-id', + cwd: '/tmp/project', + originator: 'hapi-codex-client', + cli_version: '0.142.3', + source: 'vscode', + forked_from_id: 'original-session-id' + } + }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fork hello' }] } }) + ].join('\n')) + + const sessions = listLocalCodexSessionSummaries() + const original = sessions.find((session) => session.id === 'original-session-id') + const fork = sessions.find((session) => session.id === 'fork-session-id') + + expect(original).toMatchObject({ + source: 'vscode', + threadSource: 'user', + forkedFromId: null + }) + expect(fork).toMatchObject({ + source: 'vscode', + threadSource: null, + forkedFromId: 'original-session-id' + }) + + rmSync(root, { recursive: true, force: true }) + }) + + it('uses the latest session_index thread name as the title', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionsDir = join(root, 'sessions', '2026', '07', '19') + mkdirSync(sessionsDir, { recursive: true }) + + writeFileSync(join(sessionsDir, 'session.jsonl'), [ + JSON.stringify({ type: 'session_meta', payload: { id: 'indexed-session-id', cwd: '/tmp/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fallback title' }] } }) + ].join('\n')) + writeFileSync(join(root, 'session_index.jsonl'), [ + JSON.stringify({ id: 'indexed-session-id', thread_name: 'old title', updated_at: '2026-07-19T01:00:00Z' }), + JSON.stringify({ id: 'indexed-session-id', thread_name: 'latest title', updated_at: '2026-07-19T02:00:00Z' }) + ].join('\n')) + + expect(listLocalCodexSessionSummaries()[0]?.title).toBe('latest title') + rmSync(root, { recursive: true, force: true }) + }) + + it('skips subagent transcripts', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionsDir = join(root, 'sessions', '2026', '06', '27') + mkdirSync(sessionsDir, { recursive: true }) + + writeFileSync(join(sessionsDir, 'subagent.jsonl'), [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'subagent-session-id', + cwd: '/tmp/project', + source: { subagent: 'worker' } + } + }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hidden' }] } }) + ].join('\n')) + + expect(listLocalCodexSessionSummaries()).toHaveLength(0) + + rmSync(root, { recursive: true, force: true }) + }) + + it('loads messages only for requested session ids', () => { + const root = mkdtempSync(join(tmpdir(), 'codex-home-')) + process.env.CODEX_HOME = root + const sessionsDir = join(root, 'sessions', '2026', '06', '27') + mkdirSync(sessionsDir, { recursive: true }) + + writeFileSync(join(sessionsDir, 'wanted.jsonl'), [ + JSON.stringify({ type: 'session_meta', payload: { id: 'wanted-session-id', cwd: '/tmp/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'wanted' }] } }) + ].join('\n')) + writeFileSync(join(sessionsDir, 'other.jsonl'), [ + JSON.stringify({ type: 'session_meta', payload: { id: 'other-session-id', cwd: '/tmp/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'other' }] } }) + ].join('\n')) + + const sessions = listLocalCodexSessionsWithMessagesByIds(new Set(['wanted-session-id'])) + + expect(sessions.map((session) => session.id)).toEqual(['wanted-session-id']) + expect(sessions[0]?.messages).toHaveLength(1) + rmSync(root, { recursive: true, force: true }) + }) +}) diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts new file mode 100644 index 00000000..40297c0a --- /dev/null +++ b/cli/src/modules/common/codexSessions.ts @@ -0,0 +1,446 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import { basename, dirname, join, relative } from 'node:path' +import { homedir } from 'node:os' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' + +const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 200 + +type CodexSessionIndexTitle = { + threadName: string + updatedAt: string +} + +type CodexImportedMessageContent = { + role: 'user' + content: { type: 'text'; text: string } + meta: { sentFrom: 'cli' } +} | { + role: 'agent' + content: { type: typeof AGENT_MESSAGE_PAYLOAD_TYPE; data: unknown } + meta: { sentFrom: 'cli' } +} + +export type LocalCodexSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null + source?: string | null + threadSource?: string | null + forkedFromId?: string | null +} + +export type LocalCodexSessionWithMessages = LocalCodexSessionSummary & { + messages: CodexImportedMessageContent[] +} + +export type ArchiveLocalCodexSessionOptions = { + canArchive?: (session: LocalCodexSessionSummary) => boolean | Promise +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : null +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function extractCodexText(value: unknown): string { + if (typeof value === 'string') return value.trim() + if (Array.isArray(value)) { + return value.map((item) => { + const record = asRecord(item) + if (record?.type === 'text' && typeof record.text === 'string') return record.text + if (record?.type === 'input_text' && typeof record.text === 'string') return record.text + if (record?.type === 'output_text' && typeof record.text === 'string') return record.text + return null + }).filter((part): part is string => Boolean(part)).join(' ').trim() + } + const record = asRecord(value) + if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim() + if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim() + if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim() + return '' +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value +} + +function shouldIgnoreSyntheticUserMessage(text: string): boolean { + const normalized = text.trim() + return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith('') +} + +function isSubagentSource(value: unknown): boolean { + const record = asRecord(value) + return Boolean(record && Object.prototype.hasOwnProperty.call(record, 'subagent')) +} + +function inferSessionIdFromFileName(filePath: string): string | null { + return /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/.exec(filePath)?.[1] ?? null +} + +function collectJsonlFiles(root: string, files: string[]): void { + let entries: import('node:fs').Dirent[] + try { + entries = readdirSync(root, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const fullPath = join(root, entry.name) + if (entry.isDirectory()) collectJsonlFiles(fullPath, files) + else if (entry.isFile() && fullPath.toLowerCase().endsWith('.jsonl')) files.push(fullPath) + } +} + +function getCodexHome(): string { + return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex') +} + +function getCodexSessionRoots(): string[] { + const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex') + return [join(codexHome, 'sessions')] +} + +function getCodexSessionIndexPath(): string { + return join(getCodexHome(), 'session_index.jsonl') +} + +function readCodexSessionIndexTitles(): Map { + let content: string + try { + content = readFileSync(getCodexSessionIndexPath(), 'utf-8') + } catch { + return new Map() + } + + const titles = new Map() + for (const line of content.split(/\r?\n/).filter(Boolean)) { + try { + const record = asRecord(JSON.parse(line)) + const id = typeof record?.id === 'string' ? record.id : null + const threadName = typeof record?.thread_name === 'string' && record.thread_name.trim() + ? record.thread_name.trim() + : null + const updatedAt = typeof record?.updated_at === 'string' && record.updated_at.trim() + ? record.updated_at.trim() + : null + if (!id || !threadName || !updatedAt) continue + + const previous = titles.get(id) + if (!previous || previous.updatedAt < updatedAt) { + titles.set(id, { threadName, updatedAt }) + } + } catch { + continue + } + } + return titles +} + +function extractCodexChangedTitle(record: Record): string | null { + if (record.type === 'response_item') { + const payload = asRecord(record.payload) + if (payload?.type === 'function_call' && payload.name === 'change_title' && typeof payload.arguments === 'string') { + try { + const parsed = JSON.parse(payload.arguments) as { title?: unknown } + return typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim() : null + } catch { return null } + } + } + if (record.type === 'event_msg') { + const payload = asRecord(record.payload) + const invocation = asRecord(payload?.invocation) + const args = asRecord(invocation?.arguments) + if (payload?.type === 'mcp_tool_call_end' && invocation?.tool === 'change_title' && typeof args?.title === 'string' && args.title.trim()) { + return args.title.trim() + } + } + return null +} + +function getLatestCodexChangedTitle(lines: string[]): string | null { + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const record = asRecord(JSON.parse(lines[index])) + if (!record) continue + const title = extractCodexChangedTitle(record) + if (title) return title + } catch { continue } + } + return null +} + +function getLatestCodexUserMessage(lines: string[]): string | null { + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const record = asRecord(JSON.parse(lines[index])) + if (!record || record.type !== 'response_item') continue + const payload = asRecord(record.payload) + if (payload?.type !== 'message' || payload.role !== 'user') continue + const text = extractCodexText(payload.content) + if (text && !shouldIgnoreSyntheticUserMessage(text)) return truncateText(text, 140) + } catch { continue } + } + return null +} + +function getCodexSessionTitle(cwd: string | null | undefined, sessionId: string, sessionIndexTitle: string | null, changedTitle: string | null, firstUserMessage: string | null): string { + if (sessionIndexTitle) return truncateText(sessionIndexTitle, 80) + if (changedTitle) return changedTitle + if (firstUserMessage) return truncateText(firstUserMessage, 80) + if (cwd) return basename(cwd) || cwd + return sessionId.slice(0, 8) +} + +function parseCodexFunctionArguments(value: unknown): unknown { + if (typeof value !== 'string') return value + const trimmed = value.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value + try { return JSON.parse(trimmed) } catch { return value } +} + +function extractCodexToolCallId(payload: Record): string | null { + for (const key of ['call_id', 'callId', 'tool_call_id', 'toolCallId', 'id']) { + const value = payload[key] + if (typeof value === 'string' && value.length > 0) return value + } + return null +} + +function buildImportedUserMessage(text: string): CodexImportedMessageContent { + return { role: 'user', content: { type: 'text', text }, meta: { sentFrom: 'cli' } } +} + +function buildImportedAgentMessage(data: unknown): CodexImportedMessageContent { + return { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data }, meta: { sentFrom: 'cli' } } +} + +function convertCodexRecordToImportedMessage(record: Record): CodexImportedMessageContent | null { + const type = asString(record.type) + const payload = asRecord(record.payload) + if (!type || !payload) return null + if (type === 'event_msg') { + const eventType = asString(payload.type) + if (eventType === 'user_message') { + const text = asString(payload.message) ?? asString(payload.text) ?? asString(payload.content) + return text && !shouldIgnoreSyntheticUserMessage(text) ? buildImportedUserMessage(text) : null + } + if (eventType === 'agent_message') { + const message = asString(payload.message) + return message ? buildImportedAgentMessage({ type: 'message', message, id: randomUUID() }) : null + } + if (eventType === 'token_count') { + const info = asRecord(payload.info) + return info ? buildImportedAgentMessage({ type: 'token_count', info, id: randomUUID() }) : null + } + return null + } + if (type !== 'response_item') return null + const itemType = asString(payload.type) + if (itemType === 'message') { + const role = asString(payload.role) + const text = extractCodexText(payload.content) + if (!text || shouldIgnoreSyntheticUserMessage(text)) return null + if (role === 'user') return buildImportedUserMessage(text) + if (role === 'assistant') return buildImportedAgentMessage({ type: 'message', message: text, id: randomUUID() }) + } + if (itemType === 'function_call') { + const name = asString(payload.name) + const callId = extractCodexToolCallId(payload) + return name && callId ? buildImportedAgentMessage({ type: 'tool-call', name, callId, input: parseCodexFunctionArguments(payload.arguments), id: randomUUID() }) : null + } + if (itemType === 'function_call_output') { + const callId = extractCodexToolCallId(payload) + return callId ? buildImportedAgentMessage({ type: 'tool-call-result', callId, output: payload.output, id: randomUUID() }) : null + } + return null +} + +function stableSerialize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]` + const record = value as Record + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` +} + +function normalizeComparableContent(content: unknown): string | null { + const record = asRecord(content) + if (!record) return null + if (record.role === 'user') { + const body = asRecord(record.content) + return body?.type === 'text' && typeof body.text === 'string' + ? stableSerialize({ role: 'user', text: body.text.replace(/\s+$/u, '') }) + : null + } + if (record.role === 'agent') { + const body = asRecord(record.content) + const data = asRecord(body?.data) + const normalized = data ? { ...data } : body?.data + if (data) delete (normalized as Record).id + return body?.type === AGENT_MESSAGE_PAYLOAD_TYPE ? stableSerialize({ role: 'agent', data: normalized }) : null + } + return null +} + +function deduplicateAdjacentImportedMessages(messages: CodexImportedMessageContent[]): CodexImportedMessageContent[] { + const deduped: CodexImportedMessageContent[] = [] + let previousKey: string | null = null + for (const message of messages) { + const key = normalizeComparableContent(message) + if (key && key === previousKey) continue + deduped.push(message) + previousKey = key + } + return deduped +} + +function parseCodexLocalSession( + filePath: string, + includeMessages: boolean, + sessionIndexTitles = new Map() +): LocalCodexSessionWithMessages | LocalCodexSessionSummary | null { + let content: string + try { content = readFileSync(filePath, 'utf-8') } catch { return null } + const lines = content.split(/\r?\n/).filter(Boolean) + const headLines = lines.slice(0, 200) + let sessionId: string | null = null + let cwd: string | null = null + let originator: string | null = null + let cliVersion: string | null = null + let source: string | null = null + let threadSource: string | null = null + let forkedFromId: string | null = null + let firstUserMessage: string | null = null + const messages: CodexImportedMessageContent[] = [] + + if (includeMessages) { + for (const line of lines) { + let record: Record | null = null + try { record = asRecord(JSON.parse(line)) } catch { continue } + if (!record) continue + const message = convertCodexRecordToImportedMessage(record) + if (message) messages.push(message) + } + } + + for (const line of headLines) { + try { + const record = asRecord(JSON.parse(line)) + if (!record) continue + if (record.type === 'session_meta') { + const payload = asRecord(record.payload) + if (isSubagentSource(payload?.source)) return null + if (!sessionId && typeof payload?.id === 'string') sessionId = payload.id + if (!cwd && typeof payload?.cwd === 'string') cwd = payload.cwd + if (!originator && typeof payload?.originator === 'string') originator = payload.originator + if (!cliVersion && typeof payload?.cli_version === 'string') cliVersion = payload.cli_version + if (!source && typeof payload?.source === 'string') source = payload.source + if (!threadSource && typeof payload?.thread_source === 'string') threadSource = payload.thread_source + if (!forkedFromId && typeof payload?.forked_from_id === 'string') forkedFromId = payload.forked_from_id + } + if (!firstUserMessage && record.type === 'response_item') { + const payload = asRecord(record.payload) + if (payload?.type === 'message' && payload.role === 'user') { + const text = extractCodexText(payload.content) + if (text && !shouldIgnoreSyntheticUserMessage(text)) firstUserMessage = text + } + } + } catch { continue } + } + + sessionId = sessionId ?? inferSessionIdFromFileName(filePath) + if (!sessionId) return null + const sessionIndexTitle = sessionIndexTitles.get(sessionId)?.threadName ?? null + const changedTitle = getLatestCodexChangedTitle(lines) + const lastUserMessage = getLatestCodexUserMessage(lines) + let modifiedAt = Date.now() + try { modifiedAt = statSync(filePath).mtimeMs } catch {} + const summary = { + id: sessionId, + title: getCodexSessionTitle(cwd, sessionId, sessionIndexTitle, changedTitle, firstUserMessage), + lastUserMessage, + cwd, + file: filePath, + modifiedAt, + originator, + cliVersion, + source, + threadSource, + forkedFromId + } + return includeMessages ? { ...summary, messages: deduplicateAdjacentImportedMessages(messages) } : summary +} + +function listLocalCodexSessions(includeMessages: false, limit?: number): LocalCodexSessionSummary[] +function listLocalCodexSessions(includeMessages: true, limit?: number): LocalCodexSessionWithMessages[] +function listLocalCodexSessions(includeMessages: boolean, limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Array { + const files: string[] = [] + for (const root of getCodexSessionRoots()) collectJsonlFiles(root, files) + const sessionIndexTitles = readCodexSessionIndexTitles() + const deduped = new Map() + for (const file of files) { + const session = parseCodexLocalSession(file, includeMessages, sessionIndexTitles) + if (!session) continue + const previous = deduped.get(session.id) + if (!previous || previous.modifiedAt < session.modifiedAt) deduped.set(session.id, session) + } + return Array.from(deduped.values()).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, limit) +} + +export function listLocalCodexSessionSummaries(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): LocalCodexSessionSummary[] { + return listLocalCodexSessions(false, limit) +} + +export function listLocalCodexSessionsWithMessages(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): LocalCodexSessionWithMessages[] { + return listLocalCodexSessions(true, limit) +} + +export function listLocalCodexSessionsWithMessagesByIds(ids: Set): LocalCodexSessionWithMessages[] { + if (ids.size === 0) return [] + const sessionIndexTitles = readCodexSessionIndexTitles() + return listLocalCodexSessionSummaries(Number.MAX_SAFE_INTEGER) + .filter((session) => ids.has(session.id)) + .map((session) => parseCodexLocalSession(session.file, true, sessionIndexTitles)) + .filter((session): session is LocalCodexSessionWithMessages => Boolean(session)) +} + + +export async function archiveLocalCodexSession(sessionId: string, options: ArchiveLocalCodexSessionOptions = {}): Promise<{ success: true; archivedPath: string } | { success: false; error: string }> { + const normalizedId = sessionId.trim() + if (!normalizedId) return { success: false, error: 'sessionId is required' } + + const sessionsRoot = getCodexSessionRoots()[0] + const archivedRoot = join(getCodexHome(), 'archived_sessions') + const sessions = listLocalCodexSessionSummaries(DEFAULT_CODEX_SESSION_SCAN_LIMIT * 5) + const target = sessions.find((session) => session.id === normalizedId) + if (!target) return { success: false, error: 'Codex session not found' } + if (options.canArchive && !(await options.canArchive(target))) { + return { success: false, error: 'Codex session is outside workspace roots' } + } + + const relativePath = relative(sessionsRoot, target.file) + if (!relativePath || relativePath.startsWith('..')) { + return { success: false, error: 'Codex session file is outside local sessions root' } + } + + const archivedPath = join(archivedRoot, relativePath) + try { + mkdirSync(dirname(archivedPath), { recursive: true }) + if (existsSync(archivedPath)) { + return { success: false, error: 'Archived Codex session already exists' } + } + renameSync(target.file, archivedPath) + return { success: true, archivedPath } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : 'Failed to archive Codex session' } + } +} diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 11c5f076..4bff5be8 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -4,6 +4,7 @@ export interface SpawnSessionOptions { machineId?: string directory: string sessionId?: string + existingSessionId?: string resumeSessionId?: string approvedNewDirectoryCreation?: boolean agent?: AgentFlavor diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index afc208cc..ca4f095d 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -92,6 +92,45 @@ describe('buildCliArgs', () => { expect(args).not.toContain('--service-tier') }) + it('passes existing Hapi session id separately from Codex resume thread', () => { + const args = buildCliArgs('codex', { + directory: '/tmp', + resumeSessionId: 'codex-thread-1', + existingSessionId: 'hapi-session-1', + model: 'gpt-5.5', + modelReasoningEffort: 'low', + }) + expect(args).toEqual([ + 'codex', + 'resume', + 'codex-thread-1', + '--hapi-starting-mode', + 'remote', + '--started-by', + 'runner', + '--existing-session-id', + 'hapi-session-1', + '--model', + 'gpt-5.5', + '--model-reasoning-effort', + 'low', + ]) + }) + + + + it('does not pass Codex-only existing session id flag to non-Codex agents', () => { + const args = buildCliArgs('claude', { + directory: '/tmp', + resumeSessionId: 'claude-session-1', + existingSessionId: 'hapi-session-1', + }) + expect(args).toContain('--resume') + expect(args).toContain('claude-session-1') + expect(args).not.toContain('--existing-session-id') + expect(args).not.toContain('hapi-session-1') + }) + it('validates all known permission modes', () => { for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) { const args = buildCliArgs('claude', { diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 2f8fad6d..2c0868a4 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -1115,6 +1115,12 @@ export function buildCliArgs( } } args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner'); + if (agent === 'codex') { + const existingSessionId = options.existingSessionId ?? options.sessionId; + if (existingSessionId) { + args.push('--existing-session-id', existingSessionId); + } + } if (options.model) { args.push('--model', options.model); } diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index 6b123658..1b8c2ca0 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -12,6 +12,7 @@ import { setSessionModel, setSessionModelReasoningEffort, setSessionServiceTier, + setSessionActive, setSessionTeamState, setSessionTodos, touchSessionUpdatedAt, @@ -87,6 +88,10 @@ export class SessionStore { return setSessionServiceTier(this.db, id, serviceTier, namespace, options) } + setSessionActive(id: string, active: boolean, activeAt: number, namespace: string): boolean { + return setSessionActive(this.db, id, active, activeAt, namespace) + } + touchSessionUpdatedAt(id: string, updatedAt: number, namespace: string): boolean { return touchSessionUpdatedAt(this.db, id, updatedAt, namespace) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 406e473a..9d03e059 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -539,6 +539,38 @@ export function setSessionEffort( } } +export function setSessionActive( + db: Database, + id: string, + active: boolean, + activeAt: number, + namespace: string +): boolean { + try { + const result = db.prepare(` + UPDATE sessions + SET active = @active, + active_at = CASE + WHEN active_at IS NULL OR active_at < @active_at THEN @active_at + ELSE active_at + END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND (active IS NOT @active OR active_at IS NULL OR active_at < @active_at) + `).run({ + id, + namespace, + active: active ? 1 : 0, + active_at: activeAt + }) + + return result.changes === 1 + } catch { + return false + } +} + export function touchSessionUpdatedAt( db: Database, id: string, diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index daadb9a7..e79d7705 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,6 +1,10 @@ import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' -import { CursorChatStoreStatusSchema } from '@hapi/protocol/apiTypes' +import { + ArchiveCodexSessionRpcResponseSchema, + CursorChatStoreStatusSchema, + ListCodexSessionsRpcResponseSchema +} from '@hapi/protocol/apiTypes' import type { CodexModelSummary, CodexModelsResponse, @@ -15,6 +19,8 @@ import type { GrokModelsResponse, GrokReasoningEffortResponse, ListDirectoryResponse, + ListCodexSessionsRpcResponse, + ArchiveCodexSessionRpcResponse, OpencodeModelsResponse, OpencodeModelSummary, OpencodeReasoningEffortResponse, @@ -59,6 +65,8 @@ export type RpcListDirectoryResponse = ListDirectoryResponse export type RpcPathExistsResponse = PathExistsResponse export type RpcCodexModel = CodexModelSummary export type RpcListCodexModelsResponse = CodexModelsResponse +export type RpcListCodexSessionsResponse = ListCodexSessionsRpcResponse +export type RpcArchiveCodexSessionResponse = ArchiveCodexSessionRpcResponse export type RpcCursorModel = CursorModelSummary export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcCursorChatStoreStatus = CursorChatStoreStatus @@ -146,13 +154,14 @@ export class RpcGateway { resumeSessionId?: string, effort?: string, permissionMode?: PermissionMode, - serviceTier?: string + serviceTier?: string, + existingSessionId?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, RPC_METHODS.SpawnHappySession, - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, sessionId: existingSessionId } ) if (result && typeof result === 'object') { const obj = result as Record @@ -287,6 +296,16 @@ export class RpcGateway { return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse } + async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.ListCodexSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS) + return ListCodexSessionsRpcResponseSchema.parse(result) + } + + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { + const result = await this.machineRpc(machineId, RPC_METHODS.ArchiveCodexSession, { sessionId }, MODEL_LIST_RPC_TIMEOUT_MS) + return ArchiveCodexSessionRpcResponseSchema.parse(result) + } + async listCursorModelsForSession(sessionId: string): Promise { return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index e056bbf1..0fd6de7f 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -186,6 +186,32 @@ export class SessionCache { } } + markSessionActive(sessionId: string, time: number = Date.now()): void { + const t = clampAliveTime(time) ?? Date.now() + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (!session) return + + const wasActive = session.active + session.active = true + session.activeAt = Math.max(session.activeAt, t) + + this.lastBroadcastAtBySessionId.set(session.id, Date.now()) + this.publisher.emit({ + type: 'session-updated', + sessionId: session.id, + namespace: session.namespace, + data: { + active: true, + activeAt: session.activeAt, + thinking: session.thinking + } satisfies SessionPatch + }) + + if (!wasActive) { + this.refreshSession(sessionId) + } + } + handleSessionAlive(payload: { sid: string time: number @@ -401,6 +427,7 @@ export class SessionCache { } session.active = false + this.store.sessions.setSessionActive(session.id, false, t, session.namespace) session.thinking = false session.thinkingAt = t session.backgroundTaskCount = 0 @@ -421,6 +448,7 @@ export class SessionCache { if (!session.active) continue if (now - session.activeAt <= sessionTimeoutMs) continue session.active = false + this.store.sessions.setSessionActive(session.id, false, now, session.namespace) session.thinking = false this.pendingThinkingUntilBySessionId.delete(session.id) expired.push(session.id) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index a44bbe4d..fdc750de 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -715,6 +715,58 @@ describe('session model', () => { } }) + it('marks a resumed session active in hub cache before returning success without persisting runtime active state', async () => { + const store = new Store(':memory:') + const events: unknown[] = [] + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast(event: unknown) { events.push(event) } } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-resume-active-state', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + ;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id }) + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(engine.getSession(session.id)?.active).toBe(true) + // 中文注释:active=true 是运行时状态,不能跨 Hub 重启持久化;否则旧会话会在重启后假在线。 + expect(store.sessions.getSession(session.id)?.active).toBe(false) + expect(events.some((event) => { + const record = event as { type?: string; sessionId?: string; data?: { active?: boolean } } + return record.type === 'session-updated' + && record.sessionId === session.id + && record.data?.active === true + })).toBe(true) + } finally { + engine.stop() + } + }) + it('passes resume session ID to rpc gateway when resuming claude session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( @@ -965,6 +1017,66 @@ describe('session model', () => { } }) + it('does not let stale default resume option override persisted Codex yolo', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-yolo-resume', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + preferredPermissionMode: 'yolo' + }, + null, + 'default', + 'gpt-5' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedPermissionMode: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string + ) => { + capturedPermissionMode = permissionMode + return { type: 'success', sessionId: session.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.resumeSession(session.id, 'default', { permissionMode: 'default' }) + + expect(result).toEqual({ type: 'success', sessionId: session.id }) + expect(capturedPermissionMode).toBe('yolo') + } finally { + engine.stop() + } + }) + it('passes the cached permissionMode when respawning a resumed session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index ffd5d766..4e4451e9 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -30,6 +30,7 @@ import { type RpcGeneratedImageResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcArchiveCodexSessionResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, type RpcListGrokModelsResponse, @@ -807,7 +808,8 @@ export class SyncEngine { resumeSessionId?: string, effort?: string, permissionMode?: PermissionMode, - serviceTier?: string + serviceTier?: string, + existingSessionId?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -821,7 +823,8 @@ export class SyncEngine { resumeSessionId, effort, permissionMode, - serviceTier + serviceTier, + existingSessionId ) } @@ -1274,9 +1277,12 @@ export class SyncEngine { } } - const preferredPermissionMode = opts?.permissionMode - ?? session.permissionMode - ?? session.metadata?.preferredPermissionMode + const metadataPermissionMode = session.metadata?.preferredPermissionMode + const preferredPermissionMode = metadataPermissionMode === 'yolo' && opts?.permissionMode === 'default' + ? metadataPermissionMode + : opts?.permissionMode + ?? session.permissionMode + ?? metadataPermissionMode const spawnResult = await this.rpcGateway.spawnSession( targetMachine.id, directory, @@ -1289,7 +1295,8 @@ export class SyncEngine { resumeToken, session.effort ?? undefined, preferredPermissionMode, - session.serviceTier ?? undefined + session.serviceTier ?? undefined, + access.sessionId ) if (spawnResult.type !== 'success') { @@ -1332,6 +1339,7 @@ export class SyncEngine { } } + this.sessionCache.markSessionActive(spawnResult.sessionId) return { type: 'success', sessionId: spawnResult.sessionId } } @@ -1690,6 +1698,14 @@ export class SyncEngine { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]) { + return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds) + } + + async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise { + return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId) + } + async listCursorModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCursorModelsForSession(sessionId) } diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts index 33bbfcc7..b806116c 100644 --- a/hub/src/web/routes/codexDesktop.test.ts +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -433,6 +433,51 @@ describe('Codex Desktop import routes', () => { } }) + it('updates an existing forked import when syncing the original Codex session id', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-source-test-')) + const store = new Store(':memory:') + const codexSessionId = '12121212-1212-4121-8121-121212121212' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + + const first = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + expect(first.success).toBe(true) + + const imported = store.sessions.getSessionsByNamespace('default')[0] + expect(imported).toBeDefined() + store.sessions.updateSessionMetadata(imported.id, { + ...(imported.metadata ?? {}), + codexSessionId: 'fork-session-id', + codexSourceSessionId: codexSessionId + }, imported.metadataVersion, 'default') + + const second = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(second.success).toBe(true) + const sessions = store.sessions.getSessionsByNamespace('default') + expect(sessions).toHaveLength(1) + expect(sessions[0]?.metadata).toMatchObject({ + codexSessionId: 'fork-session-id', + codexSourceSessionId: codexSessionId + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + it('deduplicates mirrored event_msg and response_item user messages', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-')) const store = new Store(':memory:') @@ -727,12 +772,6 @@ describe('Codex Desktop import routes', () => { } ]) - const app = createRoutesApp('default') - const response = await app.request('/api/codex/sessions') - expect(response.status).toBe(200) - const body = await response.json() as { sessions: Array<{ id: string; title: string }> } - expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title') - const result = await importSelectedCodexSessions({ codexSessionIds: [codexSessionId], store, @@ -762,12 +801,6 @@ describe('Codex Desktop import routes', () => { try { createTranscript(codexHome, codexSessionId) - const app = createRoutesApp('default') - const response = await app.request('/api/codex/sessions') - expect(response.status).toBe(200) - const body = await response.json() as { sessions: Array<{ id: string; title: string }> } - expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') - const result = await importSelectedCodexSessions({ codexSessionIds: [codexSessionId], store, @@ -804,12 +837,6 @@ describe('Codex Desktop import routes', () => { } ]) - const app = createRoutesApp('default') - const response = await app.request('/api/codex/sessions') - expect(response.status).toBe(200) - const body = await response.json() as { sessions: Array<{ id: string; title: string }> } - expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message') - const result = await importSelectedCodexSessions({ codexSessionIds: [codexSessionId], store, @@ -925,7 +952,7 @@ describe('Codex Desktop import routes', () => { } }) - it('keeps an existing machineId when updating an imported transcript', async () => { + it('does not append a Runner transcript to a session bound to another machine', async () => { const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-existing-test-')) const store = new Store(':memory:') const codexSessionId = '55555555-5555-4555-8555-555555555555' @@ -947,15 +974,21 @@ describe('Codex Desktop import routes', () => { codexSessionIds: [codexSessionId], store, namespace: 'default', - getSyncEngine: () => engine + getSyncEngine: () => engine, + machineId: 'machine-new' }) expect(result.success).toBe(true) - const session = store.sessions.getSessionsByNamespace('default')[0] - expect(session.metadata).toMatchObject({ - path: '/home/user/workspace/project', - machineId: 'machine-existing' - }) + const sessions = store.sessions.getSessionsByNamespace('default') + expect(sessions).toHaveLength(2) + expect(sessions.some((session) => ( + (session.metadata as Record | null)?.path === '/home/user/workspace/project' + && (session.metadata as Record | null)?.machineId === 'machine-new' + ))).toBe(true) + expect(sessions.some((session) => ( + (session.metadata as Record | null)?.path === '/home/user/workspace/project' + && (session.metadata as Record | null)?.machineId === 'machine-existing' + ))).toBe(true) } finally { store.close() rmSync(codexHome, { recursive: true, force: true }) @@ -981,13 +1014,68 @@ describe('Codex Desktop import routes', () => { const app = createRoutesApp('default') const response = await app.request('/api/codex/sessions') - expect(response.status).toBe(200) + expect(response.status).toBe(503) expect(await response.json()).toEqual({ - success: true, + success: false, + error: 'No online machine available for Codex history import', sessions: [] }) } finally { rmSync(codexHome, { recursive: true, force: true }) } }) + + it('does not fall back to another Runner when the requested machine is offline', async () => { + const store = new Store(':memory:') + let listCalls = 0 + const engine = { + getOnlineMachinesByNamespace: () => [createMachine('online-machine', ['/tmp'])], + listCodexSessionsForMachine: async () => { + listCalls += 1 + return { success: true, sessions: [] } + } + } as unknown as SyncEngine + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createCodexDesktopRoutes({ store, getSyncEngine: () => engine })) + + try { + const response = await app.request('/api/codex/sessions?machineId=offline-machine') + expect(response.status).toBe(503) + expect(listCalls).toBe(0) + } finally { + store.close() + } + }) + + it('treats source and fork ids as the same duplicate-sessions group', async () => { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + const store = new Store(':memory:') + const forkSession = store.sessions.getOrCreateSession('fork-session-id', { codexSessionId: 'fork-session-id', codexSourceSessionId: 'original-session-id' }, {}, 'default') + const dupSession = store.sessions.getOrCreateSession('dup-session-id', { codexSessionId: 'original-session-id' }, {}, 'default') + app.route('/api', createCodexDesktopRoutes({ + store, + getSyncEngine: () => null + })) + + const response = await app.request('/api/codex/duplicate-sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionIds: ['original-session-id'] }) + }) + + expect(response.status).toBe(200) + const body = await response.json() as { success: true; duplicates: Array<{ codexSessionId: string; hapiSessionIds: string[] }> } + expect(body.success).toBe(true) + expect(body.duplicates).toHaveLength(1) + expect(body.duplicates[0]?.codexSessionId).toBe('original-session-id') + expect(body.duplicates[0]?.hapiSessionIds.sort()).toEqual([dupSession.id, forkSession.id].sort()) + }) }) diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index 82e5d71f..a2299189 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -29,6 +29,7 @@ type ScriptLaunchResponse = { codexClientAvailable?: boolean syncedCount?: number sessionIds?: string[] + hapiSessionIds?: string[] } | { success: false error: string @@ -39,6 +40,7 @@ type ScriptLaunchResponse = { codexClientAvailable?: boolean syncedCount?: number sessionIds?: string[] + hapiSessionIds?: string[] } type CodexDesktopStatus = { @@ -66,6 +68,12 @@ type CodexLocalSessionSummary = { type CodexLocalSessionsResponse = { success: true sessions: CodexLocalSessionSummary[] + machineId?: string +} | { + success: false + error: string + sessions: [] + machineId?: string } type CodexImportedMessageContent = { @@ -102,6 +110,7 @@ type CodexSessionIndexTitle = { threadName: string updatedAt: string } +type RemoteCodexSession = CodexTranscriptImportData type ImportCandidate = { sessionId: string @@ -117,6 +126,11 @@ type ImportTargetSelection = { type SyncSessionRequestParseResult = { sessionIds: string[] + cwd?: string | null + machineId?: string | null + model?: string | null + modelReasoningEffort?: string | null + yolo?: boolean error?: string } @@ -894,10 +908,65 @@ function resolveImportMachineId( return machineIds.length === 1 ? machineIds[0] : undefined } + +function resolveCodexImportMachineId( + cwd: string | null | undefined, + namespace: string, + engine: SyncEngine | null, + requestedMachineId?: string | null +): string | null { + if (!engine) return null + const onlineMachines = engine.getOnlineMachinesByNamespace(namespace) + if (requestedMachineId) { + return onlineMachines.some((machine) => machine.id === requestedMachineId) + ? requestedMachineId + : null + } + if (cwd) { + const resolved = resolveImportMachineId(cwd, namespace, engine) + if (resolved) return resolved + } + return onlineMachines.length === 1 ? onlineMachines[0].id : null +} + +function asRemoteCodexSessions(value: unknown, requireMessages: boolean): RemoteCodexSession[] { + if (!Array.isArray(value)) return [] + return value.filter((session): session is RemoteCodexSession => { + const record = asRecord(session) + return typeof record?.id === 'string' + && typeof record.title === 'string' + && typeof record.file === 'string' + && typeof record.modifiedAt === 'number' + && (!requireMessages || Array.isArray(record.messages)) + }) +} + +async function listCodexSessionsViaMachine(options: { + engine: SyncEngine | null + namespace: string + cwd?: string | null + machineId?: string | null + sessionIds?: string[] +}): Promise<{ sessions: RemoteCodexSession[]; machineId?: string; error?: string }> { + const machineId = resolveCodexImportMachineId(options.cwd, options.namespace, options.engine, options.machineId) + if (!machineId || !options.engine) { + return { sessions: [], error: 'No online machine available for Codex history import' } + } + const result = await options.engine.listCodexSessionsForMachine(machineId, options.cwd, options.sessionIds) + if (!result || typeof result !== 'object') { + return { sessions: [], machineId, error: 'Unexpected Codex sessions RPC response' } + } + if ((result as { success?: unknown }).success !== true) { + return { sessions: [], machineId, error: typeof (result as { error?: unknown }).error === 'string' ? (result as { error: string }).error : 'Failed to list local Codex sessions' } + } + return { sessions: asRemoteCodexSessions((result as { sessions?: unknown }).sessions, Boolean(options.sessionIds?.length)), machineId } +} + function buildImportedSessionMetadata( data: CodexTranscriptImportData, existingMetadata?: Record | null, - resolvedMachineId?: string + resolvedMachineId?: string, + permissionMode?: string ): Record { const now = Date.now() const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) @@ -907,6 +976,9 @@ function buildImportedSessionMetadata( const machineId = typeof existingMetadata?.machineId === 'string' ? existingMetadata.machineId : resolvedMachineId + const currentCodexSessionId = typeof existingMetadata?.codexSessionId === 'string' + ? existingMetadata.codexSessionId + : data.id return { ...(existingMetadata ?? {}), @@ -921,7 +993,11 @@ function buildImportedSessionMetadata( } : existingMetadata?.summary, flavor: 'codex', - codexSessionId: data.id, + codexSessionId: currentCodexSessionId, + codexSourceSessionId: typeof existingMetadata?.codexSourceSessionId === 'string' + ? existingMetadata.codexSourceSessionId + : data.id, + ...(permissionMode ? { preferredPermissionMode: permissionMode } : {}), ...(machineId ? { machineId } : {}), lifecycleState: typeof existingMetadata?.lifecycleState === 'string' ? existingMetadata.lifecycleState @@ -953,6 +1029,10 @@ function stableSerialize(value: unknown): string { return JSON.stringify(value) } +function normalizeComparableText(value: string): string { + return value.replace(/\s+$/u, '') +} + function normalizeComparableAgentData(value: unknown): unknown { const record = asRecord(value) if (!record) { @@ -979,7 +1059,7 @@ function normalizeComparableContent(content: unknown): string | null { } return stableSerialize({ role: 'user', - text: body.text + text: normalizeComparableText(body.text) }) } @@ -1025,20 +1105,30 @@ function collectImportCandidates( })) } +function getCodexImportIds(metadata: Record | null | undefined): string[] { + return [metadata?.codexSessionId, metadata?.codexSourceSessionId] + .filter((id): id is string => typeof id === 'string' && id.length > 0) +} + function selectImportTargetSession( store: Store, candidates: ImportCandidate[], codexSessionId: string, - importedComparableMessages: string[] + importedComparableMessages: string[], + sourceMachineId?: string | null ): ImportTargetSelection { const relatedCandidates = candidates - .filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId) + .filter((candidate) => ( + candidate.metadata?.codexSessionId === codexSessionId + || candidate.metadata?.codexSourceSessionId === codexSessionId + )) + .filter((candidate) => ( + !sourceMachineId + || typeof candidate.metadata?.machineId !== 'string' + || candidate.metadata.machineId === sourceMachineId + )) .sort((a, b) => b.updatedAt - a.updatedAt) - if (relatedCandidates.some((candidate) => candidate.active)) { - throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') - } - let bestSessionId: string | null = null let bestPrefixCount = -1 @@ -1088,18 +1178,17 @@ function listDuplicateCodexSessionGroups( const groups = new Map() for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) { - const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string' - ? candidate.metadata.codexSessionId - : null - if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) { - continue - } + for (const codexSessionId of getCodexImportIds(candidate.metadata)) { + if (!requestedSessionIds.has(codexSessionId)) { + continue + } - const existing = groups.get(codexSessionId) - if (existing) { - existing.push(candidate) - } else { - groups.set(codexSessionId, [candidate]) + const existing = groups.get(codexSessionId) + if (existing) { + existing.push(candidate) + } else { + groups.set(codexSessionId, [candidate]) + } } } @@ -1590,7 +1679,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { return { sessionIds: [] } } - const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds + const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; yolo?: unknown } + const rawSessionIds = bodyRecord.sessionIds if (!Array.isArray(rawSessionIds)) { return { sessionIds: [], error: 'Invalid sessionIds' } } @@ -1606,8 +1696,18 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { } } + const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model') + const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort') + // 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。 - return { sessionIds: Array.from(new Set(sessionIds)) } + return { + sessionIds: Array.from(new Set(sessionIds)), + cwd: typeof bodyRecord.cwd === 'string' && bodyRecord.cwd.trim() ? bodyRecord.cwd.trim() : null, + machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null, + model: hasModel ? (typeof bodyRecord.model === 'string' && bodyRecord.model.trim() ? bodyRecord.model.trim() : null) : undefined, + modelReasoningEffort: hasModelReasoningEffort ? (typeof bodyRecord.modelReasoningEffort === 'string' && bodyRecord.modelReasoningEffort.trim() ? bodyRecord.modelReasoningEffort.trim() : null) : undefined, + yolo: bodyRecord.yolo === true + } } function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { @@ -1645,6 +1745,12 @@ function createImportErrorResponse( } } +function parseImportedHapiSessionId(output?: string): string | null { + if (!output) return null + const match = /^Hapi session:\s*(.+)$/m.exec(output) + return match?.[1]?.trim() || null +} + function createImportSuccessResponse( codexSessionIds: string[], results: ScriptLaunchResponse[] @@ -1663,16 +1769,21 @@ function createImportSuccessResponse( cwd: workspace, output: combineSyncOutputs(results), sessionIds: codexSessionIds, + hapiSessionIds: results.map((result) => parseImportedHapiSessionId(result.output)).filter((id): id is string => Boolean(id)), syncedCount: results.length } } function importSingleCodexSession(options: { codexSessionId: string - localSessionsById: Map + localSessionsById: Map store: Store namespace: string getSyncEngine?: () => SyncEngine | null + model?: string | null + modelReasoningEffort?: string | null + yolo?: boolean + machineId?: string | null }): ScriptLaunchResponse { const summary = options.localSessionsById.get(options.codexSessionId) if (!summary) { @@ -1682,7 +1793,9 @@ function importSingleCodexSession(options: { } } - const transcript = parseCodexTranscriptImportData(summary) + const transcript = 'messages' in summary && Array.isArray((summary as RemoteCodexSession).messages) + ? summary as RemoteCodexSession + : parseCodexTranscriptImportData(summary) if (!transcript) { return { ...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`), @@ -1707,14 +1820,16 @@ function importSingleCodexSession(options: { options.store, candidates, options.codexSessionId, - importedComparableMessages + importedComparableMessages, + options.machineId ) const engine = options.getSyncEngine?.() ?? null const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null const metadata = buildImportedSessionMetadata( transcript, asRecord(existingStored?.metadata), - resolveImportMachineId(transcript.cwd, options.namespace, engine) + options.machineId ?? resolveImportMachineId(transcript.cwd, options.namespace, engine) ?? undefined, + options.yolo ? 'yolo' : undefined ) let sessionId = existingStored?.id ?? null @@ -1725,8 +1840,11 @@ function importSingleCodexSession(options: { randomUUID(), metadata, {}, - options.namespace - ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + options.namespace, + options.model ?? undefined, + undefined, + options.modelReasoningEffort ?? undefined + ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace, options.model ?? undefined, undefined, options.modelReasoningEffort ?? undefined) sessionId = createdSession.id created = true } else if (existingStored) { @@ -1739,6 +1857,12 @@ function importSingleCodexSession(options: { if (updatedMetadata.result !== 'success') { throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) } + if (options.model !== undefined) { + options.store.sessions.setSessionModel(existingStored.id, options.model, options.namespace, { touchUpdatedAt: false }) + } + if (options.modelReasoningEffort !== undefined) { + options.store.sessions.setSessionModelReasoningEffort(existingStored.id, options.modelReasoningEffort, options.namespace, { touchUpdatedAt: false }) + } engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) } @@ -1748,6 +1872,10 @@ function importSingleCodexSession(options: { const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 const messagesToAppend = transcript.messages.slice(comparablePrefixCount) + const targetIsActive = Boolean(candidates.find((candidate) => candidate.sessionId === sessionId)?.active) + if (targetIsActive && messagesToAppend.length > 0) { + throw new Error('当前会话正在运行且 Codex transcript 有新消息,停止或归档后再同步,避免消息顺序错乱') + } const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 @@ -1782,6 +1910,7 @@ function importSingleCodexSession(options: { cwd: getDirectImportRouteContext().workspace, output, sessionIds: [options.codexSessionId], + hapiSessionIds: [sessionId], syncedCount: 1 } } catch (error) { @@ -1798,13 +1927,18 @@ export async function importSelectedCodexSessions(options: { store: Store namespace: string getSyncEngine?: () => SyncEngine | null + localSessions?: RemoteCodexSession[] + model?: string | null + modelReasoningEffort?: string | null + yolo?: boolean + machineId?: string | null }): Promise { const codexSessionIds = options.codexSessionIds if (codexSessionIds.length === 0) { return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR) } - const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session])) + const localSessionsById = new Map((options.localSessions ?? listLocalCodexSessions()).map((session) => [session.id, session])) const results: ScriptLaunchResponse[] = [] for (const codexSessionId of codexSessionIds) { const result = importSingleCodexSession({ @@ -1812,7 +1946,11 @@ export async function importSelectedCodexSessions(options: { localSessionsById, store: options.store, namespace: options.namespace, - getSyncEngine: options.getSyncEngine + getSyncEngine: options.getSyncEngine, + model: options.model, + modelReasoningEffort: options.modelReasoningEffort, + yolo: options.yolo, + machineId: options.machineId }) results.push(result) @@ -1854,13 +1992,59 @@ export function createCodexDesktopRoutes(options: { } satisfies CodexDesktopStatusResponse) }) - app.get('/codex/sessions', (c) => { + app.get('/codex/sessions', async (c) => { + const cwd = c.req.query('cwd')?.trim() || null + const machineId = c.req.query('machineId')?.trim() || null + const remote = await listCodexSessionsViaMachine({ + engine: options.getSyncEngine(), + namespace: c.get('namespace'), + cwd, + machineId + }) + if (remote.error) { + return c.json({ + success: false, + error: remote.error, + sessions: [], + ...(remote.machineId ? { machineId: remote.machineId } : {}) + } satisfies CodexLocalSessionsResponse, 503) + } return c.json({ success: true, - sessions: listLocalCodexSessions() + sessions: remote.sessions.map(({ messages: _messages, ...summary }) => summary), + ...(remote.machineId ? { machineId: remote.machineId } : {}) } satisfies CodexLocalSessionsResponse) }) + + app.post('/codex/archive-session', async (c) => { + const body = await c.req.json().catch(() => null) + const record = asRecord(body) + const sessionId = typeof record?.sessionId === 'string' ? record.sessionId.trim() : '' + const requestedMachineId = typeof record?.machineId === 'string' ? record.machineId.trim() : null + if (!sessionId) { + return c.json({ success: false, error: 'sessionId is required' }, 400) + } + + const engine = options.getSyncEngine() + const machineId = resolveCodexImportMachineId(null, c.get('namespace'), engine, requestedMachineId) + if (!engine || !machineId) { + return c.json({ success: false, error: 'No online machine available for Codex history archive' }, 503) + } + + const result = await engine.archiveCodexSessionForMachine(machineId, sessionId) + if (!result || typeof result !== 'object') { + return c.json({ success: false, error: 'Unexpected Codex archive RPC response', machineId }, 500) + } + if ((result as { success?: unknown }).success !== true) { + const error = typeof (result as { error?: unknown }).error === 'string' + ? (result as { error: string }).error + : 'Failed to archive Codex session' + return c.json({ success: false, error, machineId }, 500) + } + return c.json({ success: true, archivedPath: (result as { archivedPath: string }).archivedPath, machineId }) + }) + app.post('/codex/sync-session', async (c) => { const codexStatus = getCodexDesktopStatus() const body = await c.req.json().catch(() => null) @@ -1877,12 +2061,34 @@ export function createCodexDesktopRoutes(options: { }) } - // 中文注释:这里直接读取本地 transcript 写入 Hapi store,不再启动隐藏 codex resume 进程,避免漏导入客户端新增内容。 + // 中文注释:hub 可能运行在服务器上;Codex transcript 必须通过用户本机 runner RPC 读取,不能扫描服务器磁盘。 + const remote = await listCodexSessionsViaMachine({ + engine: options.getSyncEngine(), + namespace: c.get('namespace'), + cwd: parsed.cwd, + machineId: parsed.machineId, + sessionIds: parsed.sessionIds + }) + if (remote.error) { + const { workspace } = getDirectImportRouteContext() + return c.json({ + success: false, + error: remote.error, + cwd: workspace, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + }) + } const result = await importSelectedCodexSessions({ codexSessionIds: parsed.sessionIds, store: options.store, namespace: c.get('namespace'), - getSyncEngine: options.getSyncEngine + getSyncEngine: options.getSyncEngine, + localSessions: remote.sessions, + machineId: remote.machineId ?? null, + model: parsed.model, + modelReasoningEffort: parsed.modelReasoningEffort, + yolo: parsed.yolo }) return c.json({ ...result, diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index f09a9708..10af4860 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -126,6 +126,58 @@ export const CursorChatStoreStatusSchema = z.object({ export type CursorChatStoreStatus = z.infer +export const CodexImportedMessageSchema = z.union([ + z.object({ + role: z.literal('user'), + content: z.object({ type: z.literal('text'), text: z.string() }), + meta: z.object({ sentFrom: z.literal('cli') }) + }), + z.object({ + role: z.literal('agent'), + content: z.object({ type: z.literal('codex'), data: z.unknown() }), + meta: z.object({ sentFrom: z.literal('cli') }) + }) +]) + +export const CodexLocalSessionSummarySchema = z.object({ + id: z.string().min(1), + title: z.string(), + lastUserMessage: z.string().nullable().optional(), + cwd: z.string().nullable().optional(), + file: z.string().min(1), + modifiedAt: z.number(), + originator: z.string().nullable().optional(), + cliVersion: z.string().nullable().optional(), + source: z.string().nullable().optional(), + threadSource: z.string().nullable().optional(), + forkedFromId: z.string().nullable().optional() +}) + +export const CodexLocalSessionWithMessagesSchema = CodexLocalSessionSummarySchema.extend({ + messages: z.array(CodexImportedMessageSchema) +}) + +export const ListCodexSessionsRpcRequestSchema = z.object({ + cwd: z.string().nullable().optional(), + sessionIds: z.array(z.string().min(1)).optional() +}) + +export const ListCodexSessionsRpcResponseSchema = z.union([ + z.object({ success: z.literal(true), sessions: z.array(z.union([CodexLocalSessionSummarySchema, CodexLocalSessionWithMessagesSchema])) }), + z.object({ success: z.literal(false), error: z.string() }) +]) + +export const ArchiveCodexSessionRpcRequestSchema = z.object({ sessionId: z.string().min(1) }) +export const ArchiveCodexSessionRpcResponseSchema = z.union([ + z.object({ success: z.literal(true), archivedPath: z.string() }), + z.object({ success: z.literal(false), error: z.string() }) +]) + +export type ListCodexSessionsRpcRequest = z.infer +export type ListCodexSessionsRpcResponse = z.infer +export type ArchiveCodexSessionRpcRequest = z.infer +export type ArchiveCodexSessionRpcResponse = z.infer + export const SessionCollaborationModeRequestSchema = z.object({ mode: CodexCollaborationModeSchema }) diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 7e6d2f09..c6a195a8 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -27,6 +27,8 @@ export const RPC_METHODS = { ListSlashCommands: 'listSlashCommands', ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', + ListCodexSessions: 'listCodexSessions', + ArchiveCodexSession: 'archiveCodexSession', ListCursorModels: 'listCursorModels', ListPiModels: 'listPiModels', ListOpencodeModels: 'listOpencodeModels', diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 196f6811..38babfc2 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -35,6 +35,9 @@ export const MetadataSchema = z.object({ machineId: z.string().optional(), claudeSessionId: z.string().optional(), codexSessionId: z.string().optional(), + // 原始 Codex thread id。导入 Codex 历史后,HAPI 会 fork 出自己的续写 thread; + // codexSessionId 保存 fork 后的 thread,codexSourceSessionId 保留来源 thread 便于同步/展示。 + codexSourceSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), grokSessionId: z.string().optional(), diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index bfa38faa..5f1b55ec 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -59,6 +59,7 @@ export type SessionSummary = { /** Epoch ms of the soonest uninvoked future scheduled message, or null. */ nextScheduledAt: number | null model: string | null + modelReasoningEffort?: string | null effort: string | null } @@ -146,6 +147,7 @@ export function toSessionSummary(session: Session): SessionSummary { futureScheduledMessageCount: 0, nextScheduledAt: null, model: session.model, + modelReasoningEffort: session.modelReasoningEffort, effort: session.effort } } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7d3cd2a5..3edab788 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,6 +7,7 @@ import type { CodexDesktopScriptResponse, CodexDesktopSyncRequest, CodexDesktopStatusResponse, + CodexArchiveSessionResponse, CodexCollaborationMode, FileSearchResponse, MachinesResponse, @@ -211,8 +212,19 @@ export class ApiClient { }) } - async getCodexSessions(): Promise { - return await this.request('/api/codex/sessions') + async getCodexSessions(cwd?: string | null, machineId?: string | null): Promise { + const params = new URLSearchParams() + if (cwd?.trim()) params.set('cwd', cwd.trim()) + if (machineId?.trim()) params.set('machineId', machineId.trim()) + const query = params.size ? `?${params.toString()}` : '' + return await this.request(`/api/codex/sessions${query}`) + } + + async archiveCodexSession(sessionId: string, machineId?: string | null): Promise { + return await this.request('/api/codex/archive-session', { + method: 'POST', + body: JSON.stringify({ sessionId, machineId: machineId ?? undefined }) + }) } async getCodexDesktopStatus(): Promise { diff --git a/web/src/components/CodexSessionSyncDialog.test.tsx b/web/src/components/CodexSessionSyncDialog.test.tsx index 81f524f2..cd0c9495 100644 --- a/web/src/components/CodexSessionSyncDialog.test.tsx +++ b/web/src/components/CodexSessionSyncDialog.test.tsx @@ -8,7 +8,8 @@ function renderDialog( sessions: CodexLocalSessionSummary[] = [], onConfirm = vi.fn(async () => {}), currentCodexSessionId: string | null = null, - onRestartCodexDesktop = vi.fn(async () => {}) + onRestartCodexDesktop = vi.fn(async () => {}), + currentWorkDirectory: string | null = null ) { const view = render( @@ -17,6 +18,7 @@ function renderDialog( onClose={vi.fn()} sessions={sessions} currentCodexSessionId={currentCodexSessionId} + currentWorkDirectory={currentWorkDirectory} onConfirm={onConfirm} onRestartCodexDesktop={onRestartCodexDesktop} isPending={false} @@ -31,6 +33,7 @@ function renderDialog( describe('CodexSessionSyncDialog', () => { afterEach(() => { cleanup() + window.localStorage.clear() }) it('shows the working directory for local Codex sessions', () => { @@ -51,6 +54,45 @@ describe('CodexSessionSyncDialog', () => { expect(screen.getAllByText('/home/user/project')).toHaveLength(2) }) + + it('defaults the work directory filter to the current session directory when available', () => { + renderDialog([ + { + id: 'codex-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.codex/sessions/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + }, + { + id: 'codex-session-2', + title: 'Project two', + cwd: '/home/user/project-two', + file: '/home/user/.codex/sessions/two.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5) + } + ], undefined, null, undefined, '/home/user/project-two') + + expect(screen.queryByText('Project one')).not.toBeInTheDocument() + expect(screen.getByText('Project two')).toBeInTheDocument() + expect(screen.getByLabelText('Work directory')).toHaveValue('/home/user/project-two') + }) + + it('keeps all work directories when the current session directory is not in Codex sessions', () => { + renderDialog([ + { + id: 'codex-session-1', + title: 'Project one', + cwd: '/home/user/project-one', + file: '/home/user/.codex/sessions/one.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5) + } + ], undefined, null, undefined, '/home/user/project-missing') + + expect(screen.getByText('Project one')).toBeInTheDocument() + expect(screen.getByLabelText('Work directory')).toHaveValue('__all__') + }) + it('filters sessions by working directory', () => { renderDialog([ { @@ -149,6 +191,42 @@ describe('CodexSessionSyncDialog', () => { expect(onConfirm).toHaveBeenCalledWith(['codex-session-2']) }) + + it('opens archive menu on context menu and archives the selected Codex session', async () => { + const onArchiveSession = vi.fn(async () => {}) + render( + + {})} + onRestartCodexDesktop={vi.fn(async () => {})} + onArchiveSession={onArchiveSession} + isPending={false} + isRestartingCodexDesktop={false} + isLoading={false} + /> + + ) + + fireEvent.contextMenu(screen.getByText('Session title')) + fireEvent.click(await screen.findByRole('button', { name: 'Archive in Codex' })) + + await waitFor(() => { + expect(onArchiveSession).toHaveBeenCalledWith(expect.objectContaining({ id: 'codex-session-1' })) + }) + }) + it('keeps the restart control clear of the close button area', () => { renderDialog() @@ -166,4 +244,39 @@ describe('CodexSessionSyncDialog', () => { expect(onRestartCodexDesktop).toHaveBeenCalledTimes(1) }) + + it('shows imported badge on original session and fork badge on forked session', () => { + window.localStorage.setItem('hapi.codexImportedSessions', JSON.stringify({ + 'original-session-id': Date.now() + })) + + renderDialog([ + { + id: 'fork-session-id', + title: 'Fork session', + cwd: '/home/user/project', + file: '/home/user/.codex/sessions/fork.jsonl', + modifiedAt: Date.UTC(2026, 0, 3, 3, 4, 5), + originator: 'hapi-codex-client', + cliVersion: '0.142.3', + source: 'vscode', + forkedFromId: 'original-session-id' + }, + { + id: 'original-session-id', + title: 'Original session', + cwd: '/home/user/project', + file: '/home/user/.codex/sessions/original.jsonl', + modifiedAt: Date.UTC(2026, 0, 2, 3, 4, 5), + originator: 'Codex Desktop', + cliVersion: '0.142.2', + source: 'vscode', + threadSource: 'user' + } + ]) + + expect(screen.getByText('Original')).toBeInTheDocument() + expect(screen.getByText('Imported')).toBeInTheDocument() + expect(screen.getByText('Fork')).toBeInTheDocument() + }) }) diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx index 3e7b2cd3..b51a7e68 100644 --- a/web/src/components/CodexSessionSyncDialog.tsx +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -9,6 +9,7 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { useTranslation } from '@/lib/use-translation' +import { readCodexImportedSessions, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' const ALL_WORKDIR_FILTER = '__all__' @@ -31,13 +32,25 @@ function getCodexSessionCwd(session: CodexLocalSessionSummary): string | null { return cwd ? cwd : null } +function isOriginalCodexSession(session: CodexLocalSessionSummary): boolean { + return session.threadSource === 'user' || !session.forkedFromId +} + +function isForkedCodexSession(session: CodexLocalSessionSummary): boolean { + return typeof session.forkedFromId === 'string' && session.forkedFromId.trim().length > 0 +} + export function CodexSessionSyncDialog(props: { isOpen: boolean onClose: () => void sessions: CodexLocalSessionSummary[] currentCodexSessionId: string | null + currentWorkDirectory?: string | null onConfirm: (sessionIds: string[]) => Promise + onSelectOnly?: (session: CodexLocalSessionSummary) => void + selectionMode?: 'single' | 'multiple' onRestartCodexDesktop: () => Promise + onArchiveSession?: (session: CodexLocalSessionSummary) => Promise isPending: boolean isRestartingCodexDesktop: boolean isLoading: boolean @@ -47,8 +60,12 @@ export function CodexSessionSyncDialog(props: { isOpen, sessions, currentCodexSessionId, + currentWorkDirectory, onConfirm, + onSelectOnly, + selectionMode = 'multiple', onRestartCodexDesktop, + onArchiveSession, isPending, isRestartingCodexDesktop, isLoading, @@ -56,8 +73,16 @@ export function CodexSessionSyncDialog(props: { } = props const [selectedSessionIds, setSelectedSessionIds] = useState([]) const [hasInitializedSelection, setHasInitializedSelection] = useState(false) + const [hasInitializedWorkdirFilter, setHasInitializedWorkdirFilter] = useState(false) const [workdirFilter, setWorkdirFilter] = useState(ALL_WORKDIR_FILTER) + const [searchQuery, setSearchQuery] = useState('') + const [archiveError, setArchiveError] = useState(null) const wasOpenRef = useRef(false) + const [importedSessions, setImportedSessions] = useState(() => readCodexImportedSessions()) + const [archiveMenuSessionId, setArchiveMenuSessionId] = useState(null) + const longPressTimerRef = useRef | null>(null) + + useEffect(() => subscribeCodexImportedSessions(() => setImportedSessions(readCodexImportedSessions())), []) const sessionIdSet = useMemo( () => new Set(sessions.map((session) => session.id)), @@ -75,17 +100,32 @@ export function CodexSessionSyncDialog(props: { } return Array.from(directories).sort((a, b) => a.localeCompare(b)) }, [sessions]) + const defaultWorkdirFilter = useMemo(() => { + const directory = currentWorkDirectory?.trim() + if (!directory || !workdirOptions.includes(directory)) return ALL_WORKDIR_FILTER + return directory + }, [currentWorkDirectory, workdirOptions]) const filteredSessions = useMemo(() => { - if (workdirFilter === ALL_WORKDIR_FILTER) return sessions - return sessions.filter((session) => getCodexSessionCwd(session) === workdirFilter) - }, [sessions, workdirFilter]) + const query = searchQuery.trim().toLowerCase() + return sessions.filter((session) => { + if (workdirFilter !== ALL_WORKDIR_FILTER && getCodexSessionCwd(session) !== workdirFilter) return false + if (!query) return true + return [session.title, session.lastUserMessage, session.cwd, session.originator, session.cliVersion, session.id] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .some((value) => value.toLowerCase().includes(query)) + }) + }, [searchQuery, sessions, workdirFilter]) useEffect(() => { if (isOpen && !wasOpenRef.current) { wasOpenRef.current = true setSelectedSessionIds([]) setHasInitializedSelection(false) + setHasInitializedWorkdirFilter(false) setWorkdirFilter(ALL_WORKDIR_FILTER) + setSearchQuery('') + setArchiveError(null) + closeArchiveMenu() return } @@ -93,30 +133,73 @@ export function CodexSessionSyncDialog(props: { wasOpenRef.current = false setSelectedSessionIds([]) setHasInitializedSelection(false) + setHasInitializedWorkdirFilter(false) setWorkdirFilter(ALL_WORKDIR_FILTER) + setSearchQuery('') + setArchiveError(null) + closeArchiveMenu() } - }, [isOpen]) + }, [defaultWorkdirFilter, isOpen]) + + useEffect(() => { + if (!isOpen || isLoading || hasInitializedWorkdirFilter || sessions.length === 0) return + // 中文注释:必须等本地 transcript 列表加载完成后再按当前目录初始化,否则从目录树 + 进入时会因选项未加载而落到“全部目录”。 + setWorkdirFilter(defaultWorkdirFilter) + setHasInitializedWorkdirFilter(true) + }, [defaultWorkdirFilter, hasInitializedWorkdirFilter, isLoading, isOpen, sessions.length]) useEffect(() => { if (workdirFilter === ALL_WORKDIR_FILTER) return if (workdirOptions.includes(workdirFilter)) return - setWorkdirFilter(ALL_WORKDIR_FILTER) - }, [workdirFilter, workdirOptions]) + setWorkdirFilter(defaultWorkdirFilter) + }, [defaultWorkdirFilter, workdirFilter, workdirOptions]) useEffect(() => { if (!isOpen || isLoading || hasInitializedSelection) return // 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。 - const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) + const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) && !importedSessions[currentCodexSessionId] ? [currentCodexSessionId] : [] setSelectedSessionIds(defaultSelected) setHasInitializedSelection(true) - }, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet]) + }, [currentCodexSessionId, hasInitializedSelection, importedSessions, isLoading, isOpen, sessionIdSet]) + + + const clearLongPressTimer = () => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + } + + const openArchiveMenu = (sessionId: string) => { + setArchiveMenuSessionId(sessionId) + } + + const closeArchiveMenu = () => { + setArchiveMenuSessionId(null) + } + + const handleArchive = async (session: CodexLocalSessionSummary) => { + if (!onArchiveSession || isPending || isLoading) return + closeArchiveMenu() + setArchiveError(null) + try { + await onArchiveSession(session) + } catch (error) { + setArchiveError(error instanceof Error ? error.message : t('codexSync.failed.body')) + } + } const toggleSession = (sessionId: string) => { if (isPending || isLoading) return + if (selectionMode === 'single') { + setSelectedSessionIds([sessionId]) + return + } + // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 setSelectedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) @@ -135,6 +218,12 @@ export function CodexSessionSyncDialog(props: { const handleConfirm = async () => { if (selectedSessionIds.length === 0 || isPending || isLoading) return + if (selectionMode === 'single' && onSelectOnly) { + const selected = sessions.find((session) => session.id === selectedSessionIds[0]) + if (selected) onSelectOnly(selected) + return + } + // 中文注释:确认按钮只提交用户在弹窗中勾选的 Codex thread,实际导入逻辑由父组件统一处理并给出 toast 提示。 await onConfirm(selectedSessionIds) } @@ -165,6 +254,11 @@ export function CodexSessionSyncDialog(props: {
+ {archiveError ? ( +
+ {archiveError} +
+ ) : null}
{t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })} @@ -179,15 +273,17 @@ export function CodexSessionSyncDialog(props: { > {t('codexSync.confirm.clearAll')} - + {selectionMode === 'multiple' ? ( + + ) : null}
@@ -212,6 +308,20 @@ export function CodexSessionSyncDialog(props: { ) : null} + {sessions.length > 0 ? ( + + ) : null} +
{isLoading ? (
@@ -229,16 +339,34 @@ export function CodexSessionSyncDialog(props: {
{filteredSessions.map((session) => { const checked = selectedSessionIdSet.has(session.id) + const isImported = Boolean(importedSessions[session.id]) const time = formatCodexSessionTime(session.modifiedAt) const preview = getCodexSessionPreview(session) const cwd = getCodexSessionCwd(session) + const isOriginal = isOriginalCodexSession(session) + const isForked = isForkedCodexSession(session) return ( -
+ {isOriginal ? ( + + {t('codexSync.confirm.original')} + + ) : null} + {isForked ? ( + + {t('codexSync.confirm.fork')} + + ) : null} {session.id === currentCodexSessionId ? ( {t('codexSync.confirm.current')} ) : null} + {isImported ? ( + + {t('codexSync.confirm.imported')} + + ) : null}
{preview ? (
@@ -272,7 +415,25 @@ export function CodexSessionSyncDialog(props: {
) : null}
- + {archiveMenuSessionId === session.id && onArchiveSession ? ( +
+ + +
+ ) : null} +
) })} @@ -295,7 +456,7 @@ export function CodexSessionSyncDialog(props: { onClick={() => void handleConfirm()} disabled={isPending || isLoading || selectedSessionIds.length === 0} > - {isPending ? t('codexSync.confirm.confirming') : t('codexSync.confirm.confirm')} + {selectionMode === 'single' ? t('codexSync.confirm.useSelected') : (isPending ? t('codexSync.confirm.confirming') : t('codexSync.confirm.confirm'))} diff --git a/web/src/components/NewSession/DirectorySection.tsx b/web/src/components/NewSession/DirectorySection.tsx index 4bc0ae87..db82c6e7 100644 --- a/web/src/components/NewSession/DirectorySection.tsx +++ b/web/src/components/NewSession/DirectorySection.tsx @@ -4,6 +4,28 @@ import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { useTranslation } from '@/lib/use-translation' + +function CodexImportIcon(props: { className?: string }) { + return ( + + {/* 中文注释:导入 Codex 历史依赖当前目录,放在 Browse 后面表达“选目录后导入”。 */} + + + + ) +} + function FolderIcon(props: { className?: string }) { return ( @@ -27,6 +49,8 @@ export function DirectorySection(props: { onSuggestionSelect: (index: number) => void onPathClick: (path: string) => void onChooseFolder?: () => void + onImportCodexHistory?: () => void + isImportingCodexHistory?: boolean }) { const { t } = useTranslation() @@ -72,6 +96,21 @@ export function DirectorySection(props: { {t('newSession.browse')} )} + + {props.onImportCodexHistory ? ( + + ) : null} {props.recentPaths.length > 0 && ( diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 68e44e5e..b7ae89d8 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' import type { ApiClient } from '@/api/client' -import type { Machine } from '@/types/api' +import type { CodexLocalSessionSummary, Machine } from '@/types/api' import type { GrokPermissionMode } from '@hapi/protocol' import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' @@ -52,7 +52,51 @@ import { } from './preferences' import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' +import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' +import { markCodexSessionsImported } from '@/lib/codexImportedSessions' + + + + +function CodexImportSelectButton(props: { + selectedSession: CodexLocalSessionSummary | null + isLoading: boolean + isDisabled: boolean + error: string | null + onOpen: () => void + onClear: () => void +}) { + const { t } = useTranslation() + return ( +
+
+
+
{t('codexSync.newSessionInline.title')}
+
+ {props.selectedSession ? props.selectedSession.title : t('codexSync.newSessionInline.description')} +
+
+
+ {props.selectedSession ? ( + + ) : null} + +
+
+ {props.error ?
{props.error}
: null} +
+ ) +} export function NewSession(props: { api: ApiClient @@ -68,7 +112,6 @@ export function NewSession(props: { const { t } = useTranslation() const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api) const { sessions } = useSessions(props.api) - const isFormDisabled = Boolean(isPending || props.isLoading) const { getRecentPaths, addRecentPath, getLastUsedMachineId, setLastUsedMachineId } = useRecentPaths() const [machineId, setMachineId] = useState(props.initialMachineId ?? null) @@ -87,6 +130,14 @@ export function NewSession(props: { const [worktreeName, setWorktreeName] = useState('') const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) const [error, setError] = useState(null) + const [codexImportSessions, setCodexImportSessions] = useState([]) + const [selectedCodexImportSessionId, setSelectedCodexImportSessionId] = useState(null) + const [codexImportMachineId, setCodexImportMachineId] = useState(null) + const [isLoadingCodexImportSessions, setIsLoadingCodexImportSessions] = useState(false) + const [codexImportError, setCodexImportError] = useState(null) + const [isImportingCodexSession, setIsImportingCodexSession] = useState(false) + const [isCodexImportDialogOpen, setIsCodexImportDialogOpen] = useState(false) + const isFormDisabled = Boolean(isPending || props.isLoading || isImportingCodexSession) const worktreeInputRef = useRef(null) useEffect(() => { @@ -109,6 +160,15 @@ export function NewSession(props: { savePreferredAgent(agent) }, [agent]) + useEffect(() => { + if (agent !== 'codex') { + setSelectedCodexImportSessionId(null) + setCodexImportSessions([]) + setCodexImportMachineId(null) + setCodexImportError(null) + } + }, [agent]) + useEffect(() => { savePreferredYoloMode(yoloMode) }, [yoloMode]) @@ -461,10 +521,51 @@ export function NewSession(props: { { allowEmptyQuery: true, autoSelectFirst: false } ) + + + const handleArchiveCodexImportSession = useCallback(async (session: CodexLocalSessionSummary) => { + if (!props.api) return + const result = await props.api.archiveCodexSession(session.id, codexImportMachineId ?? machineId) + if (!result.success) { + throw new Error(result.error) + } + setCodexImportSessions((current) => current.filter((item) => item.id !== session.id)) + if (selectedCodexImportSessionId === session.id) { + setSelectedCodexImportSessionId(null) + } + }, [codexImportMachineId, machineId, props.api, selectedCodexImportSessionId]) + + const loadCodexImportSessions = useCallback(async () => { + if (agent !== 'codex' || !machineId) return + setIsLoadingCodexImportSessions(true) + setCodexImportError(null) + try { + const result = await props.api.getCodexSessions(trimmedDirectory || null, machineId) + setCodexImportSessions(result.sessions) + setCodexImportMachineId(result.machineId ?? machineId) + setSelectedCodexImportSessionId((current) => current && result.sessions.some((session) => session.id === current) ? current : null) + } catch (e) { + setCodexImportSessions([]) + setCodexImportMachineId(null) + setSelectedCodexImportSessionId(null) + setCodexImportError(e instanceof Error ? e.message : t('codexSync.failed.body')) + } finally { + setIsLoadingCodexImportSessions(false) + } + }, [agent, machineId, props.api, trimmedDirectory, t]) + + const selectedCodexImportSession = useMemo( + () => codexImportSessions.find((session) => session.id === selectedCodexImportSessionId) ?? null, + [codexImportSessions, selectedCodexImportSessionId] + ) + const handleMachineChange = useCallback((newMachineId: string) => { setMachineId(newMachineId) setModel('auto') setCursorSelectedBase('auto') + setSelectedCodexImportSessionId(null) + setCodexImportSessions([]) + setCodexImportMachineId(null) const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -535,6 +636,13 @@ export function NewSession(props: { trimmedDirectory ]) + const handleSelectCodexImportSession = useCallback((session: CodexLocalSessionSummary) => { + setSelectedCodexImportSessionId(session.id) + if (session.cwd?.trim()) { + setDirectory(session.cwd.trim()) + } + }, []) + const handlePathClick = useCallback((path: string) => { setDirectory(path) }, []) @@ -627,6 +735,42 @@ export function NewSession(props: { const resolvedModelReasoningEffort = (agent === 'codex' || agent === 'opencode') && modelReasoningEffort !== 'default' ? modelReasoningEffort : undefined + + if (agent === 'codex' && selectedCodexImportSession) { + setIsImportingCodexSession(true) + const result = await props.api.syncCodexSession({ + sessionIds: [selectedCodexImportSession.id], + cwd: selectedCodexImportSession.cwd ?? trimmedDirectory, + machineId: codexImportMachineId ?? machineId, + model: resolvedModel ?? null, + modelReasoningEffort: resolvedModelReasoningEffort ?? null, + yolo: yoloMode + }) + if (result.success) { + const importedSessionId = result.hapiSessionIds?.[0] + if (!importedSessionId) { + throw new Error('Imported session id missing') + } + // 中文注释:Codex transcript 导入只会创建 Hapi 记录,不会自动启动 agent。 + // 这里立刻 resume,避免进入会话页时先看到离线,等首条消息才触发启动。 + const resumedSessionId = await props.api.resumeSession( + importedSessionId, + yoloMode ? { permissionMode: 'yolo' } : undefined + ) + haptic.notification('success') + markCodexSessionsImported([selectedCodexImportSession.id]) + clearNewSessionFormDraft() + setLastUsedMachineId(machineId) + addRecentPath(machineId, trimmedDirectory) + props.onSuccess(resumedSessionId) + return + } + setIsImportingCodexSession(false) + haptic.notification('error') + setError(result.error || result.message || t('codexSync.failed.body')) + return + } + const result = await spawnSession({ machineId, directory: trimmedDirectory, @@ -652,6 +796,7 @@ export function NewSession(props: { haptic.notification('error') setError(result.message) } catch (e) { + setIsImportingCodexSession(false) haptic.notification('error') setError(e instanceof Error ? e.message : 'Failed to create session') } @@ -702,6 +847,19 @@ export function NewSession(props: { isDisabled={isFormDisabled} onAgentChange={setAgent} /> + {agent === 'codex' ? ( + { + setIsCodexImportDialogOpen(true) + void loadCodexImportSessions() + }} + onClear={() => setSelectedCodexImportSessionId(null)} + /> + ) : null} {agent === 'opencode' ? ( + setIsCodexImportDialogOpen(false)} + sessions={codexImportSessions} + currentCodexSessionId={selectedCodexImportSessionId} + currentWorkDirectory={trimmedDirectory} + selectionMode="single" + onSelectOnly={(session) => { + handleSelectCodexImportSession(session) + setIsCodexImportDialogOpen(false) + }} + onConfirm={async () => {}} + onRestartCodexDesktop={async () => { await loadCodexImportSessions() }} + onArchiveSession={handleArchiveCodexImportSession} + isPending={false} + isRestartingCodexDesktop={false} + isLoading={isLoadingCodexImportSessions} + /> ) } diff --git a/web/src/components/SessionActionMenu.test.tsx b/web/src/components/SessionActionMenu.test.tsx index 7ae9d13b..b08e6037 100644 --- a/web/src/components/SessionActionMenu.test.tsx +++ b/web/src/components/SessionActionMenu.test.tsx @@ -89,3 +89,41 @@ describe('SessionActionMenu - Reopen action', () => { expect(screen.queryByRole('menuitem', { name: /Archive/ })).toBeNull() }) }) + +describe('SessionActionMenu - Codex sync action', () => { + it('renders Sync from Codex only when a handler is provided', () => { + const { rerender } = renderMenu({ onSyncCodex: undefined }) + + expect(screen.queryByRole('menuitem', { name: /Sync from Codex/ })).toBeNull() + + rerender( + + + + ) + + expect(screen.getByRole('menuitem', { name: /Sync from Codex/ })).toBeInTheDocument() + }) + + it('fires onSyncCodex and closes the menu when clicked', () => { + const onSyncCodex = vi.fn() + const onClose = vi.fn() + renderMenu({ onSyncCodex, onClose }) + + fireEvent.click(screen.getByRole('menuitem', { name: /Sync from Codex/ })) + + expect(onSyncCodex).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index ce02357e..1456835a 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -16,6 +16,7 @@ type SessionActionMenuProps = { sessionActive: boolean onRename: () => void onExport?: () => void + onSyncCodex?: () => void onArchive: () => void onReopen?: () => void reopenDisabledReason?: string @@ -106,6 +107,28 @@ function ReopenIcon(props: { className?: string }) { ) } +function SyncIcon(props: { className?: string }) { + return ( + + + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onSyncCodex?.() + } + const handleDelete = () => { onClose() onDelete() @@ -309,6 +338,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { ) : null} + {onSyncCodex ? ( + + ) : null} + {sessionActive ? ( +