diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 3c72576e..74e7237a 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -69,9 +69,11 @@ function createSessionStub( permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree', - initialTranscriptPath: string | null = null + initialTranscriptPath: string | null = null, + replayTranscriptHistoryOnStart = false ) { const sessionEvents: Array<{ type: string; message?: string }> = []; + const userMessages: string[] = []; const agentMessages: unknown[] = []; let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; let sessionId: string | null = null; @@ -90,6 +92,7 @@ function createSessionStub( startedBy: 'terminal' as const, startingMode: 'local' as const, codexArgs, + replayTranscriptHistoryOnStart, client: { rpcHandlerManager: { registerHandler: () => {} @@ -124,13 +127,16 @@ function createSessionStub( recordLocalLaunchFailure: (message: string, exitReason: 'switch' | 'exit') => { localLaunchFailure = { message, exitReason }; }, - sendUserMessage: () => {}, + sendUserMessage: (message: string) => { + userMessages.push(message); + }, sendAgentMessage: (message: unknown) => { agentMessages.push(message); }, queue: createQueueStub() }, sessionEvents, + userMessages, agentMessages, getLocalLaunchFailure: () => localLaunchFailure }; @@ -348,6 +354,94 @@ describe('codexLocalLauncher', () => { }); }); + it('replays existing transcript messages when importing a Codex thread into a new Hapi session', async () => { + const transcriptPath = join(tempDir, 'codex-import-transcript.jsonl'); + const { session, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); + let releaseRunBarrier: (() => void) | undefined; + harness.runBarrier = new Promise((resolve) => { + releaseRunBarrier = resolve; + }); + + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } }) + ].join('\n') + '\n' + ); + + const launcherPromise = codexLocalLauncher(session as never); + await wait(50); + + harness.sessionHookHandlers[0]?.('codex-thread-import', { + transcript_path: transcriptPath + }); + await wait(300); + + if (releaseRunBarrier) { + releaseRunBarrier(); + } + await launcherPromise; + + expect(agentMessages).toContainEqual({ + type: 'message', + message: 'old imported message', + id: expect.any(String) + }); + }); + + it('replays existing response_item chat messages when importing a Codex thread into a new Hapi session', async () => { + const transcriptPath = join(tempDir, 'codex-import-response-item-transcript.jsonl'); + const { session, userMessages, agentMessages } = createSessionStub('default', undefined, '/tmp/worktree', null, true); + let releaseRunBarrier: (() => void) | undefined; + harness.runBarrier = new Promise((resolve) => { + releaseRunBarrier = resolve; + }); + + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import-response-item' } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'old response_item user message' }] + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'old response_item assistant message' }] + } + }) + ].join('\n') + '\n' + ); + + const launcherPromise = codexLocalLauncher(session as never); + await wait(50); + + harness.sessionHookHandlers[0]?.('codex-thread-import-response-item', { + transcript_path: transcriptPath + }); + await wait(300); + + if (releaseRunBarrier) { + releaseRunBarrier(); + } + await launcherPromise; + + expect(userMessages).toContain('old response_item user message'); + expect(agentMessages).toContainEqual({ + type: 'message', + message: 'old response_item assistant message', + id: expect.any(String) + }); + }); + it('does not let a later non-clear hook replace the primary session', async () => { const primaryTranscriptPath = await writeTranscriptMeta('primary-later-hook.jsonl', 'primary-thread'); const otherTranscriptPath = await writeTranscriptMeta('later-other-transcript.jsonl', 'other-thread'); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 5d79340d..a02e23ed 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -83,6 +83,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch } const createdScanner = await createCodexSessionScanner({ transcriptPath, + // 中文注释:导入模式下允许 scanner 首次回放 transcript 全量内容,补齐 Codex 客户端里已有但 Hapi 还未看到的消息。 + replayExistingHistory: session.replayTranscriptHistoryOnStart, onSessionId: (sessionId) => { if (!isPrimarySessionId(sessionId)) { logger.debug(`[codex-local]: Ignoring transcript session id ${sessionId}; primary is ${primarySessionId}`); diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 223807b1..aad56be4 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -33,6 +33,7 @@ interface LoopOptions { modelReasoningEffort?: ReasoningEffort; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; + replayTranscriptHistoryOnStart?: boolean; onSessionReady?: (session: CodexSession) => void; } @@ -56,7 +57,8 @@ export async function loop(opts: LoopOptions): Promise { permissionMode: opts.permissionMode ?? 'default', model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, - collaborationMode: opts.collaborationMode ?? 'default' + collaborationMode: opts.collaborationMode ?? 'default', + replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false }); await runLocalRemoteSession({ diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts index dd925c18..621119ae 100644 --- a/cli/src/codex/runCodex.test.ts +++ b/cli/src/codex/runCodex.test.ts @@ -134,8 +134,21 @@ describe('runCodex', () => { })) expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ resumeSessionId: 'codex-thread-1', - collaborationMode: 'plan' + collaborationMode: 'plan', + replayTranscriptHistoryOnStart: false })) expect(mockCodexSession.setCollaborationMode).toHaveBeenLastCalledWith('plan') }) + + it('replays transcript history when attaching a new Hapi session to an existing Codex thread', async () => { + await runCodexImpl({ + workingDirectory: '/tmp/project', + resumeSessionId: 'codex-thread-2' + }) + + expect(harness.loopArgs[0]).toEqual(expect.objectContaining({ + resumeSessionId: 'codex-thread-2', + replayTranscriptHistoryOnStart: true + })) + }) }) diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 2f676250..bda6f08c 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -73,6 +73,9 @@ export async function runCodex(opts: { const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); const sessionWrapperRef: { current: CodexSession | null } = { current: null }; + // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时, + // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 + const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId); let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; @@ -353,6 +356,7 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + replayTranscriptHistoryOnStart, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index a28477ba..6c53fb17 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -17,6 +17,7 @@ export class CodexSession extends AgentSessionBase { readonly codexCliOverrides?: CodexCliOverrides; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; + readonly replayTranscriptHistoryOnStart: boolean; localLaunchFailure: LocalLaunchFailure | null = null; private transcriptPathCallbacks: Array<(path: string) => void> = []; @@ -38,6 +39,7 @@ export class CodexSession extends AgentSessionBase { model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; + replayTranscriptHistoryOnStart?: boolean; }) { super({ api: opts.api, @@ -64,6 +66,7 @@ export class CodexSession extends AgentSessionBase { this.codexCliOverrides = opts.codexCliOverrides; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; + this.replayTranscriptHistoryOnStart = opts.replayTranscriptHistoryOnStart ?? false; this.permissionMode = opts.permissionMode; this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 3abf7776..13afe113 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -32,6 +32,37 @@ describe('convertCodexEvent', () => { expect(result?.userMessage).toBe('hello user'); }); + it('converts response_item user messages', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello from response_item user' }] + } + }); + + expect(result).toEqual({ + userMessage: 'hello from response_item user' + }); + }); + + it('converts response_item assistant messages', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello from response_item assistant' }] + } + }); + + expect(result?.message).toMatchObject({ + type: 'message', + message: 'hello from response_item assistant' + }); + }); + it('converts reasoning events', () => { const result = convertCodexEvent({ type: 'event_msg', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 24ecfd24..efd7394c 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -55,6 +55,30 @@ 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 === 'input_text' && typeof record.text === 'string') return record.text; + if (record?.type === 'output_text' && typeof record.text === 'string') return record.text; + if (record?.type === '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 === 'input_text' && typeof record.text === 'string') return record.text.trim(); + if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim(); + if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim(); + return ''; +} + function parseArguments(value: unknown): unknown { if (typeof value !== 'string') { return value; @@ -194,6 +218,27 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } + if (itemType === 'message') { + const role = asString(payloadRecord.role); + const text = extractCodexText(payloadRecord.content); + if (!text) { + return null; + } + if (role === 'user') { + return { userMessage: text }; + } + if (role === 'assistant') { + return { + message: { + type: 'message', + message: text, + id: randomUUID() + } + }; + } + return null; + } + if (itemType === 'function_call') { const name = asString(payloadRecord.name); const callId = extractCallId(payloadRecord); diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index 166fee7c..d6a7db80 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -59,6 +59,27 @@ describe('codexSessionScanner', () => { expect(events[0]?.type).toBe('event_msg'); }); + it('can replay existing transcript history on first attach', async () => { + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'session-replay' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + transcriptPath, + replayExistingHistory: true, + onEvent: (event) => events.push(event) + }); + + await wait(300); + expect(events).toHaveLength(2); + expect(events[0]?.type).toBe('session_meta'); + expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' }); + }); + it('reports session id from the transcript metadata', async () => { await writeFile( transcriptPath, diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index 2c09c5dc..06bd6670 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -7,6 +7,7 @@ interface CodexSessionScannerOptions { transcriptPath: string | null; onEvent: (event: CodexSessionEvent) => void; onSessionId?: (sessionId: string) => void; + replayExistingHistory?: boolean; } export interface CodexSessionScanner { @@ -34,6 +35,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly onSessionId?: (sessionId: string) => void; private readonly fileEpochByPath = new Map(); private readonly fileSizeByPath = new Map(); + private replayExistingHistoryOnNextAttach: boolean; private observedSessionId: string | null = null; constructor(opts: CodexSessionScannerOptions) { @@ -41,6 +43,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.transcriptPath = opts.transcriptPath; this.onEvent = opts.onEvent; this.onSessionId = opts.onSessionId; + this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false; } async setTranscriptPath(transcriptPath: string): Promise { @@ -48,14 +51,14 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return; } this.transcriptPath = transcriptPath; - await this.primeTranscript(transcriptPath); + await this.prepareTranscript(transcriptPath); this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []); this.invalidate(); } protected async initialize(): Promise { if (this.transcriptPath) { - await this.primeTranscript(this.transcriptPath); + await this.prepareTranscript(this.transcriptPath); } } @@ -89,6 +92,17 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.pruneWatchers(this.transcriptPath ? [this.transcriptPath] : []); } + private async prepareTranscript(filePath: string): Promise { + if (this.replayExistingHistoryOnNextAttach) { + // 中文注释:导入既有 Codex thread 时,首次挂接 transcript 不能先 prime 到 EOF, + // 否则 Hapi 只会看到后续增量,客户端里已经存在的最新消息会被跳过。 + this.replayExistingHistoryOnNextAttach = false; + return; + } + + await this.primeTranscript(filePath); + } + private async primeTranscript(filePath: string): Promise { const { events, nextCursor } = await this.readSessionFile(filePath, 0); const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex })); diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index fbd99e42..fc862411 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -1,7 +1,27 @@ import type { Database } from 'bun:sqlite' import type { StoredMessage } from './types' -import { addMessage, cancelQueuedMessage, deleteQueuedMessageById, lookupQueuedMessage, getMessages, getFirstMessages, getDeliverableMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, getMatureScheduledMessages, getImmediateQueuedLocalMessages, countFutureScheduledBySessionIds, countFutureScheduledLocalMessages, markMessagesInvoked, mergeSessionMessages, type CancelQueuedMessageResult, type LookupQueuedMessageResult } from './messages' +import { + addMessage, + cancelQueuedMessage, + deleteQueuedMessageById, + lookupQueuedMessage, + getMessages, + getFirstMessages, + getDeliverableMessagesAfter, + getMessagesByPosition, + getUninvokedLocalMessages, + getMatureScheduledMessages, + getImmediateQueuedLocalMessages, + countFutureScheduledBySessionIds, + countFutureScheduledLocalMessages, + markMessagesInvoked, + mergeSessionMessages, + copyMessageToSession as copyStoredMessageToSession, + getAllMessages, + type CancelQueuedMessageResult, + type LookupQueuedMessageResult, +} from './messages' export class MessageStore { private readonly db: Database @@ -14,6 +34,18 @@ export class MessageStore { return addMessage(this.db, sessionId, content, localId, scheduledAt) } + copyMessageToSession( + sessionId: string, + message: Pick + ): StoredMessage { + // 中文注释:重复会话合并时需要保留源消息的时间戳和排队信息,因此走专门的复制入口而不是普通 addMessage。 + return copyStoredMessageToSession(this.db, sessionId, message) + } + + getAllMessages(sessionId: string): StoredMessage[] { + return getAllMessages(this.db, sessionId) + } + getMessages(sessionId: string, limit: number = 200): StoredMessage[] { return getMessages(this.db, sessionId, limit) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index 2c32cded..540bdc78 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -28,6 +28,11 @@ function toStoredMessage(row: DbMessageRow): StoredMessage { } } +export type CopyStoredMessageInput = Pick< + StoredMessage, + 'content' | 'createdAt' | 'localId' | 'invokedAt' | 'scheduledAt' +> + export function addMessage( db: Database, sessionId: string, @@ -92,6 +97,56 @@ export function addMessage( return toStoredMessage(row) } +export function copyMessageToSession( + db: Database, + sessionId: string, + message: CopyStoredMessageInput +): StoredMessage { + const createdAt = Number.isFinite(message.createdAt) ? message.createdAt : Date.now() + const nextSeq = getMaxSeq(db, sessionId) + 1 + + let localId = message.localId + if (localId) { + const collision = db.prepare( + 'SELECT 1 FROM messages WHERE session_id = ? AND local_id = ? LIMIT 1' + ).get(sessionId, localId) as { 1: number } | undefined + if (collision) { + // 中文注释:重复会话合并时如果 localId 撞车,给复制进目标会话的消息生成一个新 localId,避免误判成同一条已存在消息。 + localId = `${localId}:merged:${randomUUID().slice(0, 8)}` + } + } + + if (message.scheduledAt != null && !localId && message.invokedAt === null) { + // 中文注释:未来计划消息仍需要 ack 路径;异常情况下若源数据缺少 localId,这里补一个稳定可写的新值以保留调度语义。 + localId = `merged-scheduled:${randomUUID()}` + } + + const invokedAt = localId ? message.invokedAt : (message.invokedAt ?? createdAt) + const id = randomUUID() + db.prepare(` + INSERT INTO messages ( + id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at + ) VALUES ( + @id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at, @scheduled_at + ) + `).run({ + id, + session_id: sessionId, + content: JSON.stringify(message.content), + created_at: createdAt, + seq: nextSeq, + local_id: localId ?? null, + invoked_at: invokedAt ?? null, + scheduled_at: message.scheduledAt ?? null + }) + + const row = db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as DbMessageRow | undefined + if (!row) { + throw new Error('Failed to copy message into target session') + } + return toStoredMessage(row) +} + export function getMessages( db: Database, sessionId: string, @@ -106,6 +161,17 @@ export function getMessages( return rows.reverse().map(toStoredMessage) } +export function getAllMessages( + db: Database, + sessionId: string +): StoredMessage[] { + const rows = db.prepare( + 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC' + ).all(sessionId) as DbMessageRow[] + + return rows.map(toStoredMessage) +} + export function getFirstMessages( db: Database, sessionId: string, diff --git a/hub/src/sync/permissionModePersistence.test.ts b/hub/src/sync/permissionModePersistence.test.ts index 13c78941..396cda4b 100644 --- a/hub/src/sync/permissionModePersistence.test.ts +++ b/hub/src/sync/permissionModePersistence.test.ts @@ -157,4 +157,5 @@ describe('permission mode persistence', () => { expect(capturedSpawnPermissionMode).toBe('yolo') expect(configRpcCalls).toBe(0) }) + }) diff --git a/hub/src/web/routes/codexDesktop.test.ts b/hub/src/web/routes/codexDesktop.test.ts new file mode 100644 index 00000000..a892d0ce --- /dev/null +++ b/hub/src/web/routes/codexDesktop.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from 'bun:test' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Hono } from 'hono' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { Store } from '../../store' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createCodexDesktopRoutes, importSelectedCodexSessions } from './codexDesktop' + +const originalCodexHome = process.env.CODEX_HOME + +function createTranscript(codexHome: string, sessionId: string): void { + const sessionDir = join(codexHome, 'sessions', '2026', '06', '04') + mkdirSync(sessionDir, { recursive: true }) + const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`) + const lines = [ + { + type: 'session_meta', + payload: { + id: sessionId, + cwd: 'C:\\work\\project', + originator: 'codex_cli_rs', + cli_version: '0.0.0-test' + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'normal user message' }] + } + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'normal assistant message' }] + } + } + ] + writeFileSync(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') +} + +function createRoutesApp(namespace: string): Hono { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', namespace) + await next() + }) + app.route('/api', createCodexDesktopRoutes({ + store: new Store(':memory:'), + getSyncEngine: () => null + })) + return app +} + +describe('Codex Desktop import routes', () => { + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } + }) + + it('imports normal response_item chat messages', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-test-')) + const store = new Store(':memory:') + const codexSessionId = '11111111-1111-4111-8111-111111111111' + process.env.CODEX_HOME = codexHome + + try { + createTranscript(codexHome, codexSessionId) + + const result = await importSelectedCodexSessions({ + codexSessionIds: [codexSessionId], + store, + namespace: 'default', + getSyncEngine: () => null + }) + + expect(result.success).toBe(true) + const session = store.sessions.getSessionsByNamespace('default')[0] + expect(session).toBeDefined() + const messages = store.messages.getAllMessages(session.id) + expect(messages).toHaveLength(2) + expect(messages[0].content).toEqual({ + role: 'user', + content: { + type: 'text', + text: 'normal user message' + }, + meta: { + sentFrom: 'cli' + } + }) + expect(messages[1].content).toEqual({ + role: 'agent', + content: { + type: AGENT_MESSAGE_PAYLOAD_TYPE, + data: { + type: 'message', + message: 'normal assistant message', + id: expect.any(String) + } + }, + meta: { + sentFrom: 'cli' + } + }) + } finally { + store.close() + rmSync(codexHome, { recursive: true, force: true }) + } + }) + + it('rejects Codex transcript endpoints outside the default namespace', async () => { + const app = createRoutesApp('team-a') + const response = await app.request('/api/codex/sessions') + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + error: 'Codex transcript import is not available outside the default namespace' + }) + }) + + it('allows Codex transcript endpoints in the default namespace', async () => { + const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-route-test-')) + process.env.CODEX_HOME = codexHome + + try { + const app = createRoutesApp('default') + const response = await app.request('/api/codex/sessions') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + sessions: [] + }) + } finally { + rmSync(codexHome, { recursive: true, force: true }) + } + }) +}) diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts new file mode 100644 index 00000000..947239b2 --- /dev/null +++ b/hub/src/web/routes/codexDesktop.ts @@ -0,0 +1,1769 @@ +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { spawn, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname, platform } from 'node:os' +import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { Store, StoredMessage } from '../../store' +import type { WebAppEnv } from '../middleware/auth' + +type ScriptLogKind = 'sync' | 'restart' + +const DIRECT_IMPORT_COMMAND = 'direct-import' +const RESTART_SCRIPT_ENV_NAME = 'HAPI_CODEX_RESTART_SCRIPT' +const RESTART_SCRIPT_DEFAULT_FILE = 'Restart-CodexDesktop.ps1' +const RESTART_SCRIPT_ARGS = ['-Apply'] +const RESTART_SCRIPT_MESSAGE = 'Codex Desktop restart script started' + +type ScriptLaunchResponse = { + success: true + message: string + pid: number + command: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} | { + success: false + error: string + script?: string + cwd: string + output?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + syncedCount?: number + sessionIds?: string[] +} + +type CodexDesktopStatus = { + running: boolean + clientAvailable: boolean +} + +type CodexDesktopStatusResponse = { + success: true + codexDesktopRunning: boolean + codexClientAvailable: boolean +} + +type CodexLocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +type CodexLocalSessionsResponse = { + success: true + sessions: CodexLocalSessionSummary[] +} + +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' + } +} + +type CodexTranscriptImportData = CodexLocalSessionSummary & { + messages: CodexImportedMessageContent[] +} + +type ImportCandidate = { + sessionId: string + active: boolean + updatedAt: number + metadata: Record | null +} + +type ImportTargetSelection = { + sessionId: string | null + comparablePrefixCount: number +} + +type SyncSessionRequestParseResult = { + sessionIds: string[] + error?: string +} + +type CodexDuplicateSessionGroup = { + codexSessionId: string + hapiSessionIds: string[] + canonicalSessionId?: string + removedSessionIds?: string[] +} + +type CodexDuplicateSessionsResponse = { + success: true + duplicates: CodexDuplicateSessionGroup[] +} | { + success: false + error: string +} + +type CodexMergeDuplicateSessionsResponse = { + success: true + merged: CodexDuplicateSessionGroup[] + mergedCount: number +} | { + success: false + error: string +} + +type DuplicateSessionGroupCandidate = { + codexSessionId: string + sessions: ImportCandidate[] +} + +const CODEX_DESKTOP_NOT_FOUND_ERROR = '尝试重启codex客户端失败,未安装/找不到codex客户端' +const SCRIPT_TIMEOUT_ERROR = '执行超时' +const NO_SYNC_SESSION_SELECTED_ERROR = '未选择需要导入的 Codex 会话' +const CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR = 'Codex transcript import is not available outside the default namespace' +const DEFAULT_SCRIPT_TIMEOUT_MS = 60_000 +const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 500 + +function resolveLocalPath(pathValue: string): string { + return isAbsolute(pathValue) ? pathValue : resolve(process.cwd(), pathValue) +} + +function getScriptRoot(): string { + const configured = process.env.HAPI_CODEX_SCRIPT_ROOT?.trim() + return configured ? resolveLocalPath(configured) : process.cwd() +} + +function getDefaultScriptPath(defaultFile: string): string { + const configuredRoot = process.env.HAPI_CODEX_SCRIPT_ROOT?.trim() + if (configuredRoot) { + return join(resolveLocalPath(configuredRoot), defaultFile) + } + + const cwd = process.cwd() + const candidateRoots = [ + cwd, + resolve(cwd, '..'), + resolve(cwd, '..', '..') + ] + + for (const root of candidateRoots) { + const candidate = join(root, defaultFile) + if (existsSync(candidate)) { + return candidate + } + } + + return join(getScriptRoot(), defaultFile) +} + +function getRestartScriptPath(): string { + const configured = process.env[RESTART_SCRIPT_ENV_NAME]?.trim() + return configured ? resolveLocalPath(configured) : getDefaultScriptPath(RESTART_SCRIPT_DEFAULT_FILE) +} + +function getWorkspace(scriptPath: string): string { + const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() + return configured ? resolveLocalPath(configured) : dirname(scriptPath) +} + +function getDirectImportWorkspace(): string { + const configured = process.env.HAPI_CODEX_WORKSPACE?.trim() + return configured ? resolveLocalPath(configured) : process.cwd() +} + +function expandHomePath(pathValue: string): string { + return pathValue.replace(/^~(?=$|[\\/])/, homedir()) +} + +function getCodexHome(): string { + const configured = process.env.CODEX_HOME?.trim() + return configured ? resolveLocalPath(expandHomePath(configured)) : join(homedir(), '.codex') +} + +function getCodexSessionRoots(): string[] { + const codexHome = getCodexHome() + // 中文注释:当前 direct import 只从 sessions 目录解析 transcript,避免把 archived_sessions 中暂不参与导入的会话展示给用户。 + return [join(codexHome, 'sessions')] +} + +function collectJsonlFiles(root: string, files: string[]): void { + if (!existsSync(root)) return + let entries + 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) + continue + } + if (entry.isFile() && fullPath.toLowerCase().endsWith('.jsonl')) { + files.push(fullPath) + } + } +} + +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 inferSessionIdFromFileName(filePath: string): string | null { + const match = /([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) + return match?.[1] ?? null +} + +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 { + const candidates = ['call_id', 'callId', 'tool_call_id', 'toolCallId', 'id'] + for (const key of candidates) { + const value = payload[key] + if (typeof value === 'string' && value.length > 0) { + return value + } + } + return null +} + +function extractCodexChangedTitle(record: Record): string | null { + const type = typeof record.type === 'string' ? record.type : null + if (type === 'response_item') { + const payload = asRecord(record.payload) + if (payload?.type === 'function_call' && payload.name === 'change_title') { + const argumentsText = typeof payload.arguments === 'string' ? payload.arguments : null + if (!argumentsText) return null + try { + const parsedArguments = JSON.parse(argumentsText) as { title?: unknown } + return typeof parsedArguments.title === 'string' && parsedArguments.title.trim() + ? parsedArguments.title.trim() + : null + } catch { + return null + } + } + } + + if (type === 'event_msg') { + const payload = asRecord(record.payload) + if (payload?.type === 'mcp_tool_call_end') { + const invocation = asRecord(payload.invocation) + const argumentsRecord = asRecord(invocation?.arguments) + if (invocation?.tool === 'change_title' && typeof argumentsRecord?.title === 'string' && argumentsRecord.title.trim()) { + return argumentsRecord.title.trim() + } + } + } + + return null +} + +function getLatestCodexChangedTitle(lines: string[]): string | null { + // 中文注释:Codex 会在 transcript 中记录 change_title 调用;这里从后往前取最后一次成功设置的标题,作为弹窗主标题显示。 + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + const parsed = JSON.parse(lines[index]) + const record = asRecord(parsed) + 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 parsed = JSON.parse(lines[index]) + const record = asRecord(parsed) + 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, + changedTitle: string | null, + firstUserMessage: string | null +): string { + if (changedTitle) { + return truncateText(changedTitle, 80) + } + + if (firstUserMessage) { + return truncateText(firstUserMessage, 80) + } + + if (cwd) { + const parts = cwd.split(/[\\/]+/).filter(Boolean) + if (parts.length > 0) { + return parts[parts.length - 1] + } + } + + return sessionId.slice(0, 8) +} + +function isSubagentSource(value: unknown): boolean { + const record = asRecord(value) + return record ? Object.prototype.hasOwnProperty.call(record, 'subagent') : false +} + +function parseCodexLocalSession(filePath: string): CodexLocalSessionSummary | null { + let content: string + try { + content = readFileSync(filePath, 'utf-8') + } catch { + return null + } + + const allLines = content.split(/\r?\n/).filter(Boolean) + const headLines = allLines.slice(0, 200) + let sessionId: string | null = null + let cwd: string | null = null + let originator: string | null = null + let cliVersion: string | null = null + let firstUserMessage: string | null = null + + for (const line of headLines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const record = asRecord(parsed) + const type = typeof record?.type === 'string' ? record.type : null + if (type === 'session_meta') { + const payload = asRecord(record?.payload) + if (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 (!firstUserMessage && 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 + } + } + } + } + + const changedTitle = getLatestCodexChangedTitle(allLines) + const lastUserMessage = getLatestCodexUserMessage(allLines) + + sessionId = sessionId ?? inferSessionIdFromFileName(filePath) + if (!sessionId) return null + + let modifiedAt = Date.now() + try { + modifiedAt = statSync(filePath).mtimeMs + } catch { + // Fall back to current time if stat fails during a concurrent file change. + } + + return { + id: sessionId, + title: getCodexSessionTitle(cwd, sessionId, changedTitle, firstUserMessage), + lastUserMessage, + cwd, + file: filePath, + modifiedAt, + originator, + cliVersion + } +} + +function listLocalCodexSessions(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): CodexLocalSessionSummary[] { + const files: string[] = [] + for (const root of getCodexSessionRoots()) { + collectJsonlFiles(root, files) + } + + const deduped = new Map() + for (const filePath of files) { + const session = parseCodexLocalSession(filePath) + 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) +} + +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) { + return null + } + + if (eventType === 'user_message') { + const text = asString(payload.message) + ?? asString(payload.text) + ?? asString(payload.content) + if (!text || shouldIgnoreSyntheticUserMessage(text)) { + return null + } + return buildImportedUserMessage(text) + } + + if (eventType === 'agent_message') { + const message = asString(payload.message) + return message ? buildImportedAgentMessage({ type: 'message', message, id: randomUUID() }) : null + } + + if (eventType === 'agent_reasoning') { + const message = asString(payload.text) ?? asString(payload.message) + return message ? buildImportedAgentMessage({ type: 'reasoning', message, id: randomUUID() }) : null + } + + if (eventType === 'agent_reasoning_delta') { + const delta = asString(payload.delta) ?? asString(payload.text) ?? asString(payload.message) + return delta ? buildImportedAgentMessage({ type: 'reasoning-delta', delta }) : 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') { + const itemType = asString(payload.type) + if (!itemType) { + return null + } + + 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() }) + } + return null + } + + if (itemType === 'function_call') { + const name = asString(payload.name) + const callId = extractCodexToolCallId(payload) + if (!name || !callId) { + return null + } + return buildImportedAgentMessage({ + type: 'tool-call', + name, + callId, + input: parseCodexFunctionArguments(payload.arguments), + id: randomUUID() + }) + } + + if (itemType === 'function_call_output') { + const callId = extractCodexToolCallId(payload) + if (!callId) { + return null + } + return buildImportedAgentMessage({ + type: 'tool-call-result', + callId, + output: payload.output, + id: randomUUID() + }) + } + } + + return null +} + +function parseCodexTranscriptImportData(summary: CodexLocalSessionSummary): CodexTranscriptImportData | null { + let content: string + try { + content = readFileSync(summary.file, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/).filter(Boolean) + const messages: CodexImportedMessageContent[] = [] + + for (const line of lines) { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const record = asRecord(parsed) + if (!record) continue + const message = convertCodexRecordToImportedMessage(record) + if (message) { + messages.push(message) + } + } + + return { + ...summary, + messages + } +} + +function buildImportedSessionMetadata( + data: CodexTranscriptImportData, + existingMetadata?: Record | null +): Record { + const now = Date.now() + const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file)) + const host = typeof existingMetadata?.host === 'string' ? existingMetadata.host : (process.env.HAPI_HOSTNAME || hostname()) + const osValue = typeof existingMetadata?.os === 'string' ? existingMetadata.os : platform() + const summaryText = data.lastUserMessage ?? data.title + + return { + ...(existingMetadata ?? {}), + path, + host, + os: osValue, + name: data.title, + summary: summaryText + ? { + text: summaryText, + updatedAt: now + } + : existingMetadata?.summary, + flavor: 'codex', + codexSessionId: data.id, + lifecycleState: typeof existingMetadata?.lifecycleState === 'string' + ? existingMetadata.lifecycleState + : 'imported', + lifecycleStateSince: typeof existingMetadata?.lifecycleStateSince === 'number' + ? existingMetadata.lifecycleStateSince + : now + } +} + +function stableSerialize(value: unknown): string { + if (value === null || value === undefined) { + return String(value) + } + if (typeof value === 'string') { + return JSON.stringify(value) + } + if (typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + if (typeof value === 'object') { + const record = value as Record + const keys = Object.keys(record).sort() + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}` + } + return JSON.stringify(value) +} + +function normalizeComparableAgentData(value: unknown): unknown { + const record = asRecord(value) + if (!record) { + return value + } + + const normalized = { ...record } + if ('id' in normalized) { + delete normalized.id + } + return normalized +} + +function normalizeComparableContent(content: unknown): string | null { + const record = asRecord(content) + if (!record) { + return null + } + + if (record.role === 'user') { + const body = asRecord(record.content) + if (body?.type !== 'text' || typeof body.text !== 'string') { + return null + } + return stableSerialize({ + role: 'user', + text: body.text + }) + } + + if (record.role === 'agent') { + const body = asRecord(record.content) + if (!body || body.type !== AGENT_MESSAGE_PAYLOAD_TYPE) { + return null + } + return stableSerialize({ + role: 'agent', + data: normalizeComparableAgentData(body.data) + }) + } + + return null +} + +function getComparableStoredMessageKey(message: StoredMessage): string { + // 中文注释:重复会话合并时优先按标准 user/agent 结构去重;遇到非标准消息再回退到稳定序列化,确保不会遗漏相同内容。 + return normalizeComparableContent(message.content) ?? stableSerialize(message.content) +} + +function collectImportCandidates( + store: Store, + namespace: string, + getSyncEngine?: () => SyncEngine | null +): ImportCandidate[] { + const engineSessions = getSyncEngine?.()?.getSessionsByNamespace(namespace) ?? [] + if (engineSessions.length > 0) { + return engineSessions.map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) + } + + return store.sessions.getSessionsByNamespace(namespace).map((session) => ({ + sessionId: session.id, + active: session.active, + updatedAt: session.updatedAt, + metadata: asRecord(session.metadata) + })) +} + +function selectImportTargetSession( + store: Store, + candidates: ImportCandidate[], + codexSessionId: string, + importedComparableMessages: string[] +): ImportTargetSelection { + const relatedCandidates = candidates + .filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId) + .sort((a, b) => b.updatedAt - a.updatedAt) + + if (relatedCandidates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + let bestSessionId: string | null = null + let bestPrefixCount = -1 + + for (const candidate of relatedCandidates) { + const comparableMessages = store.messages.getAllMessages(candidate.sessionId) + .map((message) => normalizeComparableContent(message.content)) + .filter((value): value is string => value !== null) + + if (comparableMessages.length > importedComparableMessages.length) { + continue + } + + let prefixMatches = true + for (let index = 0; index < comparableMessages.length; index += 1) { + if (comparableMessages[index] !== importedComparableMessages[index]) { + prefixMatches = false + break + } + } + + if (!prefixMatches) { + continue + } + + if (comparableMessages.length > bestPrefixCount) { + bestPrefixCount = comparableMessages.length + bestSessionId = candidate.sessionId + } + } + + return { + sessionId: bestSessionId, + comparablePrefixCount: Math.max(0, bestPrefixCount) + } +} + +function listDuplicateCodexSessionGroups( + store: Store, + namespace: string, + codexSessionIds: string[], + getSyncEngine?: () => SyncEngine | null +): DuplicateSessionGroupCandidate[] { + const requestedSessionIds = new Set(codexSessionIds) + if (requestedSessionIds.size === 0) { + return [] + } + + 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 + } + + const existing = groups.get(codexSessionId) + if (existing) { + existing.push(candidate) + } else { + groups.set(codexSessionId, [candidate]) + } + } + + return Array.from(groups.entries()) + .map(([codexSessionId, sessions]) => ({ + codexSessionId, + sessions: sessions.sort((a, b) => b.updatedAt - a.updatedAt) + })) + .filter((group) => group.sessions.length > 1) +} + +async function mergeDuplicateCodexSessionGroups(options: { + store: Store + namespace: string + codexSessionIds: string[] + getSyncEngine?: () => SyncEngine | null +}): Promise { + const groups = listDuplicateCodexSessionGroups( + options.store, + options.namespace, + options.codexSessionIds, + options.getSyncEngine + ) + if (groups.length === 0) { + return { + success: true, + merged: [], + mergedCount: 0 + } + } + + const merged: CodexDuplicateSessionGroup[] = [] + for (const group of groups) { + const result = await mergeSingleDuplicateCodexSessionGroup({ + group, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + merged.push(result) + } + + return { + success: true, + merged, + mergedCount: merged.length + } +} + +async function mergeSingleDuplicateCodexSessionGroup(options: { + group: DuplicateSessionGroupCandidate + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): Promise { + const engine = options.getSyncEngine?.() ?? null + const sessionStates = options.group.sessions + .map((candidate) => ({ + ...candidate, + storedMessages: options.store.messages.getAllMessages(candidate.sessionId), + })) + .map((candidate) => ({ + ...candidate, + comparableKeys: candidate.storedMessages.map((message) => getComparableStoredMessageKey(message)) + })) + .sort((a, b) => { + if (b.comparableKeys.length !== a.comparableKeys.length) { + return b.comparableKeys.length - a.comparableKeys.length + } + if (b.updatedAt !== a.updatedAt) { + return b.updatedAt - a.updatedAt + } + return a.sessionId.localeCompare(b.sessionId) + }) + + if (sessionStates.some((candidate) => candidate.active)) { + throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试') + } + + const canonical = sessionStates[0] + if (!canonical) { + throw new Error(`No duplicate Hapi session found for Codex thread: ${options.group.codexSessionId}`) + } + + const knownKeys = new Set(canonical.comparableKeys) + const removedSessionIds: string[] = [] + const appendedMessages: StoredMessage[] = [] + let latestActivity = canonical.updatedAt + + for (const source of sessionStates.slice(1)) { + latestActivity = Math.max(latestActivity, source.updatedAt) + for (const message of source.storedMessages) { + const comparableKey = getComparableStoredMessageKey(message) + if (knownKeys.has(comparableKey)) { + continue + } + + const copied = options.store.messages.copyMessageToSession(canonical.sessionId, { + content: message.content, + createdAt: message.createdAt, + localId: message.localId, + invokedAt: message.invokedAt, + scheduledAt: message.scheduledAt + }) + knownKeys.add(comparableKey) + appendedMessages.push(copied) + latestActivity = Math.max(latestActivity, copied.invokedAt ?? copied.createdAt) + } + + if (engine) { + await engine.deleteSession(source.sessionId) + } else { + const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) + if (!deleted) { + throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) + } + } + removedSessionIds.push(source.sessionId) + } + + if (appendedMessages.length > 0) { + emitImportedMessageEvents(engine, canonical.sessionId, appendedMessages) + } + + if (engine) { + engine.recordSessionActivity(canonical.sessionId, latestActivity) + // 中文注释:即使这次只是删除重复分身、没有新增消息,也主动刷新 canonical 会话,确保左侧列表立刻收敛到合并后的状态。 + engine.handleRealtimeEvent({ + type: 'session-updated', + sessionId: canonical.sessionId + }) + } else { + options.store.sessions.touchSessionUpdatedAt(canonical.sessionId, latestActivity, options.namespace) + } + + return { + codexSessionId: options.group.codexSessionId, + hapiSessionIds: sessionStates.map((candidate) => candidate.sessionId), + canonicalSessionId: canonical.sessionId, + removedSessionIds + } +} + +function emitImportedMessageEvents( + engine: SyncEngine | null, + sessionId: string, + appendedMessages: StoredMessage[] +): void { + if (!engine) { + return + } + + // 中文注释:只有追加到已有 Hapi 会话时才逐条广播新增消息,确保当前打开的会话右侧消息区能立即刷新到最新 transcript。 + for (const message of appendedMessages) { + engine.handleRealtimeEvent({ + type: 'message-received', + sessionId, + message: { + id: message.id, + seq: message.seq, + localId: message.localId ?? null, + content: message.content, + createdAt: message.createdAt, + invokedAt: message.invokedAt + } + }) + } +} + +function getPathExts(): string[] { + if (process.platform !== 'win32') { + return [''] + } + const fromEnv = (process.env.PATHEXT ?? '') + .split(';') + .map(ext => ext.trim().toLowerCase()) + .filter(Boolean) + return Array.from(new Set(['', '.exe', '.cmd', '.bat', '.ps1', ...fromEnv])) +} + +function findOnPath(commandName: string): string | null { + if (commandName.includes('\\') || commandName.includes('/')) { + return existsSync(commandName) ? commandName : null + } + + const pathDirs = (process.env.PATH ?? '') + .split(process.platform === 'win32' ? ';' : ':') + .map(part => part.trim()) + .filter(Boolean) + const extensions = getPathExts() + + for (const dir of pathDirs) { + for (const ext of extensions) { + const candidate = join(dir, commandName.endsWith(ext) ? commandName : `${commandName}${ext}`) + if (existsSync(candidate)) { + return candidate + } + } + } + + return null +} + +function getCodexLauncherCandidates(): string[] { + return [ + process.env.HAPI_CODEX_COMMAND?.trim() ?? '', + findOnPath('codex') ?? '', + process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'codex.exe') : '' + ].filter(Boolean) +} + +function isCodexLauncherAvailable(): boolean { + return getCodexLauncherCandidates().some(candidate => { + try { + return existsSync(candidate) + } catch { + return false + } + }) +} + +function isCodexDesktopPath(pathValue: string): boolean { + return /\\WindowsApps\\OpenAI\.Codex_[^\\]+\\app\\(?:Codex|resources\\codex)\.exe$/i.test(pathValue) +} + +function isCodexDesktopPackageInstalled(): boolean { + if (process.platform !== 'win32') { + return false + } + + const command = [ + "$package = Get-AppxPackage -Name OpenAI.Codex -ErrorAction SilentlyContinue", + "if ($package) { 'true' } else { 'false' }" + ].join('\n') + + for (const shell of ['pwsh', 'powershell.exe']) { + try { + const result = spawnSync(shell, ['-NoLogo', '-NoProfile', '-Command', command], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true + }) + if (result.status === 0) { + return result.stdout.trim().toLowerCase().includes('true') + } + } catch { + // Try next shell. + } + } + + return false +} + +function isCodexDesktopInstallAvailable(): boolean { + if (process.platform !== 'win32') { + return isCodexLauncherAvailable() + } + + if (isCodexDesktopPackageInstalled()) { + return true + } + + return getCodexLauncherCandidates().some(candidate => { + try { + return isCodexDesktopPath(candidate) && existsSync(candidate) + } catch { + return false + } + }) +} + +function isCodexDesktopRunning(): boolean { + if (process.platform !== 'win32') { + return false + } + + const command = [ + "$targets = @(Get-CimInstance Win32_Process | Where-Object {", + " ($_.Name -ieq 'Codex.exe' -or $_.Name -ieq 'codex.exe') -and", + " $_.ExecutablePath -match '\\\\WindowsApps\\\\OpenAI\\.Codex_'", + '})', + "if ($targets.Count -gt 0) { 'true' } else { 'false' }" + ].join('\n') + + for (const shell of ['pwsh', 'powershell.exe']) { + try { + const result = spawnSync(shell, ['-NoLogo', '-NoProfile', '-Command', command], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true + }) + if (result.status === 0) { + return result.stdout.trim().toLowerCase().includes('true') + } + } catch { + // Try next shell. + } + } + + return false +} + +function getCodexDesktopStatus(): CodexDesktopStatus { + const running = isCodexDesktopRunning() + return { + running, + clientAvailable: running || isCodexDesktopInstallAvailable() + } +} + +function getScriptTimeoutMs(): number { + const configured = Number(process.env.HAPI_CODEX_SCRIPT_TIMEOUT_MS) + if (Number.isFinite(configured) && configured > 0) { + return configured + } + return DEFAULT_SCRIPT_TIMEOUT_MS +} + +function createLaunchArgs(scriptPath: string, workspace: string, scriptArgs: string[]): string[] { + return [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + scriptPath, + '-Workspace', + workspace, + ...scriptArgs + ] +} + +function appendScriptLog(workspace: string, kind: ScriptLogKind, message: string): void { + try { + const logDir = join(workspace, 'logs') + mkdirSync(logDir, { recursive: true }) + const line = `[${new Date().toISOString()}] [${kind}] ${message}\n` + appendFileSync(join(logDir, 'CodexDesktopScript.log'), line, 'utf-8') + } catch { + // Best-effort logging only; API response still carries the error. + } +} + +async function runPowerShellScript(scriptPath: string, workspace: string, scriptArgs: string[]): Promise<{ pid: number; command: string; output: string }> { + const configuredPwsh = process.env.HAPI_PWSH_PATH?.trim() + const candidates = Array.from(new Set([ + configuredPwsh || 'pwsh', + 'powershell.exe' + ])) + const args = createLaunchArgs(scriptPath, workspace, scriptArgs) + let lastError: unknown = null + + for (const command of candidates) { + try { + return await new Promise((resolvePromise, rejectPromise) => { + const output: string[] = [] + let settled = false + let didSpawn = false + let timeout: ReturnType | null = null + const child = spawn(command, args, { + cwd: workspace, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout) + } + child.off('spawn', onSpawn) + child.off('error', onError) + child.off('exit', onExit) + } + + const settleResolve = (value: { pid: number; command: string; output: string }) => { + if (settled) return + settled = true + cleanup() + resolvePromise(value) + } + + const settleReject = (error: Error) => { + if (settled) return + settled = true + cleanup() + rejectPromise(error) + } + + const onSpawn = () => { + didSpawn = true + } + + const onError = (error: Error) => { + if (!didSpawn) { + ;(error as Error & { shellLaunchFailed?: boolean }).shellLaunchFailed = true + } + settleReject(error) + } + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const combinedOutput = output.join('').trim() + if (code === 0) { + settleResolve({ pid: child.pid ?? 0, command, output: combinedOutput }) + return + } + const detail = combinedOutput ? `\n${combinedOutput}` : '' + settleReject(new Error(`${command} exited with code ${code ?? 'null'}${signal ? ` signal ${signal}` : ''}.${detail}`)) + } + + timeout = setTimeout(() => { + child.kill() + settleReject(new Error(SCRIPT_TIMEOUT_ERROR)) + }, getScriptTimeoutMs()) + + child.stdout?.on('data', (chunk) => output.push(String(chunk))) + child.stderr?.on('data', (chunk) => output.push(String(chunk))) + child.once('spawn', onSpawn) + child.once('error', onError) + child.once('exit', onExit) + }) + } catch (error) { + lastError = error + if (!(error instanceof Error && (error as Error & { shellLaunchFailed?: boolean }).shellLaunchFailed)) { + throw error instanceof Error ? error : new Error(String(error)) + } + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)) +} + +async function launchRestartScript(): Promise { + const scriptPath = getRestartScriptPath() + const workspace = getWorkspace(scriptPath) + + if (!existsSync(scriptPath)) { + appendScriptLog(workspace, 'restart', `FAILED: Script not found: ${scriptPath}`) + return { + success: false, + error: `Script not found: ${scriptPath}`, + script: scriptPath, + cwd: workspace + } + } + + if (!existsSync(workspace)) { + appendScriptLog(workspace, 'restart', `FAILED: Workspace not found: ${workspace}`) + return { + success: false, + error: `Workspace not found: ${workspace}`, + script: scriptPath, + cwd: workspace + } + } + + try { + const launched = await runPowerShellScript(scriptPath, workspace, RESTART_SCRIPT_ARGS) + const output = launched.output + appendScriptLog( + workspace, + 'restart', + `SUCCESS: ${RESTART_SCRIPT_MESSAGE}; pid=${launched.pid}; command=${launched.command}; script=${scriptPath}${output ? `; output=${output}` : ''}` + ) + return { + success: true, + message: RESTART_SCRIPT_MESSAGE, + pid: launched.pid, + command: launched.command, + script: scriptPath, + cwd: workspace, + output + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + appendScriptLog(workspace, 'restart', `FAILED: ${message}; script=${scriptPath}`) + return { + success: false, + error: message, + script: scriptPath, + cwd: workspace + } + } +} + +function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult { + // 中文注释:导入弹窗现在直接提交 Codex thread ID;未传 body 时按“未选择会话”处理,避免再回退到旧的默认最新会话逻辑。 + if (body === null || typeof body !== 'object' || Array.isArray(body) || !('sessionIds' in body)) { + return { sessionIds: [] } + } + + const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds + if (!Array.isArray(rawSessionIds)) { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + + const sessionIds: string[] = [] + for (const value of rawSessionIds) { + if (typeof value !== 'string') { + return { sessionIds: [], error: 'Invalid sessionIds' } + } + const trimmed = value.trim() + if (trimmed) { + sessionIds.push(trimmed) + } + } + + // 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。 + return { sessionIds: Array.from(new Set(sessionIds)) } +} + +function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined { + const output = results + .map((result, index) => { + // 中文注释:direct import 不再依赖隐藏脚本;这里把每个会话的导入摘要拼成一段文本,便于前端或日志统一查看。 + const detail = result.success ? (result.output ?? '') : (result.output ?? result.error) + return detail ? `[${index + 1}] ${detail}` : '' + }) + .filter(Boolean) + .join('\n\n') + .trim() + return output || undefined +} + +function getDirectImportRouteContext(): { workspace: string } { + return { + workspace: getDirectImportWorkspace() + } +} + +function createImportErrorResponse( + codexSessionIds: string[], + error: string, + syncedCount = 0 +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${error}; sessionIds=${codexSessionIds.join(',') || '(none)'}`) + return { + success: false, + error, + cwd: workspace, + sessionIds: codexSessionIds, + syncedCount + } +} + +function createImportSuccessResponse( + codexSessionIds: string[], + results: ScriptLaunchResponse[] +): ScriptLaunchResponse { + const { workspace } = getDirectImportRouteContext() + appendScriptLog( + workspace, + 'sync', + `SUCCESS: imported ${results.length} Codex session(s); sessionIds=${codexSessionIds.join(',')}` + ) + return { + success: true, + message: `Imported ${results.length} Codex session(s) into Hapi`, + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: workspace, + output: combineSyncOutputs(results), + sessionIds: codexSessionIds, + syncedCount: results.length + } +} + +function importSingleCodexSession(options: { + codexSessionId: string + localSessionsById: Map + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | null +}): ScriptLaunchResponse { + const summary = options.localSessionsById.get(options.codexSessionId) + if (!summary) { + return { + ...createImportErrorResponse([options.codexSessionId], `Transcript not found for Codex session: ${options.codexSessionId}`), + output: `未找到对应的本地 transcript:${options.codexSessionId}` + } + } + + const transcript = parseCodexTranscriptImportData(summary) + if (!transcript) { + return { + ...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`), + output: `解析 transcript 失败:${summary.file}` + } + } + + if (transcript.messages.length === 0) { + return { + ...createImportErrorResponse([options.codexSessionId], `No importable conversation content found in transcript: ${summary.file}`), + output: `transcript 中没有可导入的会话内容:${summary.file}` + } + } + + const importedComparableMessages = transcript.messages + .map((message) => normalizeComparableContent(message)) + .filter((value): value is string => value !== null) + + try { + const candidates = collectImportCandidates(options.store, options.namespace, options.getSyncEngine) + const target = selectImportTargetSession( + options.store, + candidates, + options.codexSessionId, + importedComparableMessages + ) + 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)) + + let sessionId = existingStored?.id ?? null + let created = false + if (!sessionId) { + // 中文注释:找不到可安全续写的历史会话时,直接新建一个 Hapi 会话,避免把已分叉的数据硬写进旧会话。 + const createdSession = engine?.getOrCreateSession( + randomUUID(), + metadata, + {}, + options.namespace + ) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace) + sessionId = createdSession.id + created = true + } else if (existingStored) { + const updatedMetadata = options.store.sessions.updateSessionMetadata( + existingStored.id, + metadata, + existingStored.metadataVersion, + options.namespace + ) + if (updatedMetadata.result !== 'success') { + throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`) + } + engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id }) + } + + if (!sessionId) { + throw new Error(`Failed to determine target Hapi session for Codex thread: ${options.codexSessionId}`) + } + + const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0 + const messagesToAppend = transcript.messages.slice(comparablePrefixCount) + const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message)) + + // 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。 + const latestMessageCreatedAt = appendedMessages[appendedMessages.length - 1]?.createdAt ?? Date.now() + if (engine) { + engine.recordSessionActivity(sessionId, latestMessageCreatedAt) + } else { + options.store.sessions.touchSessionUpdatedAt(sessionId, latestMessageCreatedAt, options.namespace) + } + if (!created) { + emitImportedMessageEvents(engine, sessionId, appendedMessages) + } + + const output = [ + `Codex thread: ${options.codexSessionId}`, + `Hapi session: ${sessionId}`, + `Action: ${created ? 'created' : 'updated'}`, + `Appended messages: ${appendedMessages.length}` + ].join('\n') + + appendScriptLog( + getDirectImportRouteContext().workspace, + 'sync', + `SUCCESS: codexSessionId=${options.codexSessionId}; hapiSessionId=${sessionId}; created=${created}; appended=${appendedMessages.length}` + ) + + return { + success: true, + message: created ? 'Codex session imported into a new Hapi session' : 'Codex session appended to existing Hapi session', + pid: 0, + command: DIRECT_IMPORT_COMMAND, + cwd: getDirectImportRouteContext().workspace, + output, + sessionIds: [options.codexSessionId], + syncedCount: 1 + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + ...createImportErrorResponse([options.codexSessionId], message), + output: `Codex thread: ${options.codexSessionId}\n${message}` + } + } +} + +export async function importSelectedCodexSessions(options: { + codexSessionIds: string[] + store: Store + namespace: string + getSyncEngine?: () => SyncEngine | 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 results: ScriptLaunchResponse[] = [] + for (const codexSessionId of codexSessionIds) { + const result = importSingleCodexSession({ + codexSessionId, + localSessionsById, + store: options.store, + namespace: options.namespace, + getSyncEngine: options.getSyncEngine + }) + results.push(result) + + if (!result.success) { + return { + ...result, + sessionIds: codexSessionIds, + syncedCount: Math.max(0, results.length - 1), + output: combineSyncOutputs(results) ?? result.output + } + } + } + + return createImportSuccessResponse(codexSessionIds, results) +} + +export function createCodexDesktopRoutes(options: { + store: Store + getSyncEngine: () => SyncEngine | null +}): Hono { + const app = new Hono() + + app.use('/codex/*', async (c, next) => { + if (c.get('namespace') !== 'default') { + return c.json({ + success: false, + error: CODEX_TRANSCRIPT_IMPORT_NAMESPACE_ERROR + }, 403) + } + return next() + }) + + app.get('/codex/status', (c) => { + const codexStatus = getCodexDesktopStatus() + return c.json({ + success: true, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + } satisfies CodexDesktopStatusResponse) + }) + + app.get('/codex/sessions', (c) => { + return c.json({ + success: true, + sessions: listLocalCodexSessions() + } satisfies CodexLocalSessionsResponse) + }) + + app.post('/codex/sync-session', async (c) => { + const codexStatus = getCodexDesktopStatus() + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + const { workspace } = getDirectImportRouteContext() + appendScriptLog(workspace, 'sync', `FAILED: ${parsed.error}`) + return c.json({ + success: false, + error: parsed.error, + cwd: workspace, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + }) + } + + // 中文注释:这里直接读取本地 transcript 写入 Hapi store,不再启动隐藏 codex resume 进程,避免漏导入客户端新增内容。 + const result = await importSelectedCodexSessions({ + codexSessionIds: parsed.sessionIds, + store: options.store, + namespace: c.get('namespace'), + getSyncEngine: options.getSyncEngine + }) + return c.json({ + ...result, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + }) + }) + + app.post('/codex/duplicate-sessions', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + return c.json({ + success: false, + error: parsed.error + } satisfies CodexDuplicateSessionsResponse) + } + + if (parsed.sessionIds.length === 0) { + return c.json({ + success: false, + error: NO_SYNC_SESSION_SELECTED_ERROR + } satisfies CodexDuplicateSessionsResponse) + } + + // 中文注释:这里只检查本次导入弹窗里勾选过的 codexSessionId;未选中的会话即使也有重复,也不参与本轮提示。 + const duplicates = listDuplicateCodexSessionGroups( + options.store, + c.get('namespace'), + parsed.sessionIds, + options.getSyncEngine + ).map((group) => ({ + codexSessionId: group.codexSessionId, + hapiSessionIds: group.sessions.map((session) => session.sessionId) + })) + + return c.json({ + success: true, + duplicates + } satisfies CodexDuplicateSessionsResponse) + }) + + app.post('/codex/merge-duplicate-sessions', async (c) => { + const body = await c.req.json().catch(() => null) + const parsed = parseSyncSessionRequest(body) + if (parsed.error) { + return c.json({ + success: false, + error: parsed.error + } satisfies CodexMergeDuplicateSessionsResponse) + } + + if (parsed.sessionIds.length === 0) { + return c.json({ + success: false, + error: NO_SYNC_SESSION_SELECTED_ERROR + } satisfies CodexMergeDuplicateSessionsResponse) + } + + const { workspace } = getDirectImportRouteContext() + try { + // 中文注释:真正执行合并时仍然只按这次选中的 codexSessionId 收口,防止顺手把别的会话历史也改掉。 + const result = await mergeDuplicateCodexSessionGroups({ + store: options.store, + namespace: c.get('namespace'), + codexSessionIds: parsed.sessionIds, + getSyncEngine: options.getSyncEngine + }) + appendScriptLog( + workspace, + 'sync', + `SUCCESS: merged duplicate Hapi sessions for selected codexSessionIds=${parsed.sessionIds.join(',')}` + ) + return c.json(result satisfies CodexMergeDuplicateSessionsResponse) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + appendScriptLog( + workspace, + 'sync', + `FAILED: duplicate-session merge error=${message}; selectedCodexSessionIds=${parsed.sessionIds.join(',')}` + ) + return c.json({ + success: false, + error: message + } satisfies CodexMergeDuplicateSessionsResponse) + } + }) + + app.post('/codex/restart-desktop', async (c) => { + const codexStatus = getCodexDesktopStatus() + if (!codexStatus.clientAvailable) { + const scriptPath = getRestartScriptPath() + const workspace = getWorkspace(scriptPath) + const error = CODEX_DESKTOP_NOT_FOUND_ERROR + appendScriptLog(workspace, 'restart', `FAILED: ${error}; script=${scriptPath}`) + return c.json({ + success: false, + error, + script: scriptPath, + cwd: workspace, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + }) + } + + const result = await launchRestartScript() + return c.json({ + ...result, + codexDesktopRunning: codexStatus.running, + codexClientAvailable: codexStatus.clientAvailable + }) + }) + + return app +} diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index e18f3ddc..8ebe1e82 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -17,6 +17,7 @@ import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' import { createGitRoutes } from './routes/git' import { createCliRoutes } from './routes/cli' +import { createCodexDesktopRoutes } from './routes/codexDesktop' import { createPushRoutes } from './routes/push' import { createVoiceRoutes } from './routes/voice' import type { SSEManager } from '../sse/sseManager' @@ -96,6 +97,11 @@ function createWebApp(options: { app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) app.route('/api', createGitRoutes(options.getSyncEngine)) + // 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。 + app.route('/api', createCodexDesktopRoutes({ + store: options.store, + getSyncEngine: options.getSyncEngine + })) app.route('/api', createPushRoutes(options.store, options.vapidPublicKey)) app.route('/api', createVoiceRoutes()) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d8dfa1bb..e8fd44b8 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,12 @@ import type { AttachmentMetadata, AuthResponse, + CodexLocalSessionsResponse, + CodexDuplicateSessionsResponse, + CodexMergeDuplicateSessionsResponse, + CodexDesktopScriptResponse, + CodexDesktopSyncRequest, + CodexDesktopStatusResponse, CodexCollaborationMode, FileSearchResponse, MachinesResponse, @@ -179,6 +185,44 @@ export class ApiClient { }) } + async syncCodexSession(payload?: CodexDesktopSyncRequest): Promise { + // 中文注释:当前按钮语义已改为“从 Codex 导入到 Hapi”;这里提交的是本地 transcript 对应的 Codex thread ID 列表。 + return await this.request('/api/codex/sync-session', { + method: 'POST', + ...(payload ? { body: JSON.stringify(payload) } : {}) + }) + } + + async getCodexSessions(): Promise { + return await this.request('/api/codex/sessions') + } + + async getCodexDesktopStatus(): Promise { + return await this.request('/api/codex/status') + } + + async getCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise { + // 中文注释:重复会话检测只传本次用户勾选导入的 codexSessionId,避免把未选中的历史会话也纳入提示。 + return await this.request('/api/codex/duplicate-sessions', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + + async mergeCodexDuplicateSessions(payload: CodexDesktopSyncRequest): Promise { + // 中文注释:真正执行合并时沿用同一批选中 codexSessionId,保证检测范围与执行范围一致。 + return await this.request('/api/codex/merge-duplicate-sessions', { + method: 'POST', + body: JSON.stringify(payload) + }) + } + + async restartCodexDesktop(): Promise { + return await this.request('/api/codex/restart-desktop', { + method: 'POST' + }) + } + async unsubscribePushNotifications(payload: PushUnsubscribePayload): Promise { await this.request('/api/push/subscribe', { method: 'DELETE', diff --git a/web/src/components/CodexSessionSyncDialog.tsx b/web/src/components/CodexSessionSyncDialog.tsx new file mode 100644 index 00000000..31c699cb --- /dev/null +++ b/web/src/components/CodexSessionSyncDialog.tsx @@ -0,0 +1,242 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { CodexLocalSessionSummary } from '@/types/api' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +function formatCodexSessionTime(value: number): string | null { + if (!Number.isFinite(value)) return null + return new Date(value).toLocaleString() +} + +function getCodexSessionPreview(session: CodexLocalSessionSummary): string { + if (session.lastUserMessage?.trim()) { + return session.lastUserMessage.trim() + } + + const parts = [session.originator, session.cliVersion].filter(Boolean) + return parts.join(' · ') +} + +export function CodexSessionSyncDialog(props: { + isOpen: boolean + onClose: () => void + sessions: CodexLocalSessionSummary[] + currentCodexSessionId: string | null + onConfirm: (sessionIds: string[]) => Promise + onRestartCodexDesktop: () => Promise + isPending: boolean + isRestartingCodexDesktop: boolean + isLoading: boolean +}) { + const { t } = useTranslation() + const { + isOpen, + sessions, + currentCodexSessionId, + onConfirm, + onRestartCodexDesktop, + isPending, + isRestartingCodexDesktop, + isLoading, + onClose + } = props + const [selectedSessionIds, setSelectedSessionIds] = useState([]) + const [hasInitializedSelection, setHasInitializedSelection] = useState(false) + const wasOpenRef = useRef(false) + + const sessionIdSet = useMemo( + () => new Set(sessions.map((session) => session.id)), + [sessions] + ) + const selectedSessionIdSet = useMemo( + () => new Set(selectedSessionIds), + [selectedSessionIds] + ) + + useEffect(() => { + if (isOpen && !wasOpenRef.current) { + wasOpenRef.current = true + setSelectedSessionIds([]) + setHasInitializedSelection(false) + return + } + + if (!isOpen && wasOpenRef.current) { + wasOpenRef.current = false + setSelectedSessionIds([]) + setHasInitializedSelection(false) + } + }, [isOpen]) + + useEffect(() => { + if (!isOpen || isLoading || hasInitializedSelection) return + + // 中文注释:弹窗打开后等本地 Codex 会话列表加载完成,再尝试默认勾选当前 Hapi 会话关联的 Codex thread,避免异步加载时默认值丢失。 + const defaultSelected = currentCodexSessionId && sessionIdSet.has(currentCodexSessionId) + ? [currentCodexSessionId] + : [] + setSelectedSessionIds(defaultSelected) + setHasInitializedSelection(true) + }, [currentCodexSessionId, hasInitializedSelection, isLoading, isOpen, sessionIdSet]) + + const toggleSession = (sessionId: string) => { + if (isPending || isLoading) return + + // 中文注释:列表项支持多选导入;再次点击同一行则取消勾选,便于快速调整导入批次。 + setSelectedSessionIds((current) => current.includes(sessionId) + ? current.filter((id) => id !== sessionId) + : [...current, sessionId]) + } + + const selectAll = () => { + setSelectedSessionIds(sessions.map((session) => session.id)) + } + + const clearAll = () => { + // 中文注释:全取消放在左侧,和底部“取消 / 导入”的左右语义保持一致。 + setSelectedSessionIds([]) + } + + const handleConfirm = async () => { + if (selectedSessionIds.length === 0 || isPending || isLoading) return + + // 中文注释:确认按钮只提交用户在弹窗中勾选的 Codex thread,实际导入逻辑由父组件统一处理并给出 toast 提示。 + await onConfirm(selectedSessionIds) + } + + return ( + !open && onClose()}> + +
+ + {t('codexSync.confirm.title')} + + {t('codexSync.confirm.description')} + + + +
+ +
+
+
+ {t('codexSync.confirm.selectedCount', { n: selectedSessionIds.length })} +
+
+ + +
+
+ +
+ {isLoading ? ( +
+ {t('codexSync.confirm.loading')} +
+ ) : sessions.length === 0 ? ( +
+ {t('codexSync.confirm.empty')} +
+ ) : ( +
+ {sessions.map((session) => { + const checked = selectedSessionIdSet.has(session.id) + const time = formatCodexSessionTime(session.modifiedAt) + return ( + + ) + })} +
+ )} +
+
+ +
+ + +
+
+
+ ) +} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 40ffc81d..bc39cc4d 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -16,6 +16,7 @@ import { useSessionListStatusMode } from '@/hooks/useSessionListStatusMode' import { classifySessionAttention } from '@/lib/sessionAttention' import { getSessionLastSeenAt } from '@/lib/sessionLastSeen' import { getAttentionLabel, SessionAttentionIndicator } from '@/components/SessionAttentionIndicator' +import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' type SessionGroup = { key: string @@ -521,6 +522,34 @@ function formatRelativeTime(value: number, t: (key: string, params?: Record) => string): string | null { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + if (!Number.isFinite(ms)) return null + const delta = Date.now() - ms + if (delta < 60_000) return t('session.time.importedFromCodex.justNow') + const minutes = Math.floor(delta / 60_000) + if (minutes < 60) return t('session.time.importedFromCodex.minutesAgo', { n: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) return t('session.time.importedFromCodex.hoursAgo', { n: hours }) + const days = Math.floor(hours / 24) + if (days < 7) return t('session.time.importedFromCodex.daysAgo', { n: days }) + return new Date(ms).toLocaleDateString() +} + +function getSessionTimeLabel(session: SessionSummary, t: (key: string, params?: Record) => string): string | null { + const codexSessionId = session.metadata?.agentSessionId + const importedAt = session.metadata?.flavor === 'codex' + ? getCodexImportedAt(codexSessionId) + : null + + // 中文注释:导入标记存在时优先显示“xx 前从 Codex 客户端导入”;等用户在 Hapi 里继续发消息后,再由发送逻辑清除该标记。 + if (importedAt !== null) { + return formatCodexImportedRelativeTime(importedAt, t) + } + + return formatRelativeTime(session.updatedAt, t) +} + function SessionItem(props: { session: SessionSummary onSelect: (sessionId: string) => void @@ -615,7 +644,7 @@ function SessionItem(props: { ) : null} - {formatRelativeTime(s.updatedAt, t)} + {getSessionTimeLabel(s, t)} @@ -690,9 +719,17 @@ export function SessionList(props: { const { sessionListStatusMode } = useSessionListStatusMode() const showDetailedStatus = sessionListStatusMode === 'detailed' const [searchQuery, setSearchQuery] = useState('') + const [, setCodexImportedSessionsVersion] = useState(0) const normalizedQuery = normalizeSearch(searchQuery) const isSearching = normalizedQuery.length > 0 + useEffect(() => { + // 中文注释:监听导入标记变化,让列表在“导入完成”或“用户已在 Hapi 中继续会话”后立即刷新时间文案。 + return subscribeCodexImportedSessions(() => { + setCodexImportedSessionsVersion((value) => value + 1) + }) + }, []) + const resolveMachineLabel = (machineId: string | null): string => { if (machineId && machineLabelsById[machineId]) { return machineLabelsById[machineId] diff --git a/web/src/components/ui/ConfirmDialog.tsx b/web/src/components/ui/ConfirmDialog.tsx index 712bd03e..e998d1a3 100644 --- a/web/src/components/ui/ConfirmDialog.tsx +++ b/web/src/components/ui/ConfirmDialog.tsx @@ -63,7 +63,7 @@ export function ConfirmDialog(props: ConfirmDialogProps) { {title} - + {description} diff --git a/web/src/lib/codexImportedSessions.ts b/web/src/lib/codexImportedSessions.ts new file mode 100644 index 00000000..5ee54dc5 --- /dev/null +++ b/web/src/lib/codexImportedSessions.ts @@ -0,0 +1,94 @@ +const CODEX_IMPORTED_SESSIONS_STORAGE_KEY = 'hapi.codexImportedSessions' +const CODEX_IMPORTED_SESSIONS_EVENT = 'hapi:codex-imported-sessions-updated' + +type CodexImportedSessionsMap = Record + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof localStorage !== 'undefined' +} + +function dispatchCodexImportedSessionsChanged(): void { + if (!isBrowser()) return + window.dispatchEvent(new CustomEvent(CODEX_IMPORTED_SESSIONS_EVENT)) +} + +export function readCodexImportedSessions(): CodexImportedSessionsMap { + if (!isBrowser()) return {} + try { + const raw = localStorage.getItem(CODEX_IMPORTED_SESSIONS_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {} + } + + const result: CodexImportedSessionsMap = {} + for (const [key, value] of Object.entries(parsed)) { + if (typeof key === 'string' && typeof value === 'number' && Number.isFinite(value)) { + result[key] = value + } + } + return result + } catch { + return {} + } +} + +function writeCodexImportedSessions(map: CodexImportedSessionsMap): void { + if (!isBrowser()) return + localStorage.setItem(CODEX_IMPORTED_SESSIONS_STORAGE_KEY, JSON.stringify(map)) + dispatchCodexImportedSessionsChanged() +} + +export function markCodexSessionsImported(codexSessionIds: string[], importedAt = Date.now()): void { + if (!isBrowser() || codexSessionIds.length === 0) return + + // 中文注释:以 Codex thread ID 为 key 记录导入时间,便于会话列表把时间文案切换成“从 Codex 客户端导入”。 + const next = readCodexImportedSessions() + for (const codexSessionId of codexSessionIds) { + const trimmed = codexSessionId.trim() + if (trimmed) { + next[trimmed] = importedAt + } + } + writeCodexImportedSessions(next) +} + +export function clearCodexImportedSession(codexSessionId: string | null | undefined): void { + if (!isBrowser() || !codexSessionId) return + + const next = readCodexImportedSessions() + if (!(codexSessionId in next)) return + + // 中文注释:当用户已经在 Hapi 内继续这个会话后,移除导入标记,列表时间恢复为普通“xx 分钟前”。 + delete next[codexSessionId] + writeCodexImportedSessions(next) +} + +export function getCodexImportedAt(codexSessionId: string | null | undefined): number | null { + if (!codexSessionId) return null + const importedAt = readCodexImportedSessions()[codexSessionId] + return typeof importedAt === 'number' && Number.isFinite(importedAt) ? importedAt : null +} + +export function subscribeCodexImportedSessions(onChange: () => void): () => void { + if (!isBrowser()) { + return () => {} + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === CODEX_IMPORTED_SESSIONS_STORAGE_KEY) { + onChange() + } + } + const handleCustomEvent = () => { + onChange() + } + + window.addEventListener('storage', handleStorage) + window.addEventListener(CODEX_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener(CODEX_IMPORTED_SESSIONS_EVENT, handleCustomEvent) + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 84209712..25a97542 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -53,6 +53,45 @@ export default { 'sessions.group.showMore': 'Show {n} more', 'sessions.group.showLess': 'Show less', 'sessions.group.new': 'New session in this directory', + 'codexSync.tooltip': 'Import sessions from Codex into Hapi', + 'codexSync.confirm.title': 'Import Codex sessions', + 'codexSync.confirm.description': 'Choose Codex sessions to import into Hapi', + // 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。 + 'codexSync.confirm.selectAll': 'Select all', + 'codexSync.confirm.clearAll': 'Clear all', + 'codexSync.confirm.selectedCount': '{n} sessions selected', + 'codexSync.confirm.empty': 'No local Codex sessions found', + 'codexSync.confirm.current': 'Linked', + 'codexSync.confirm.confirm': 'Import', + 'codexSync.confirm.confirming': 'Importing…', + 'codexSync.confirm.loading': 'Loading local Codex sessions…', + 'codexSync.success.title': 'Import complete', + 'codexSync.success.body': 'Imported {n} Codex session(s) into Hapi.', + 'codexSync.error.timeout': 'Operation timed out', + 'codexSync.error.active': 'This session is still active. Please wait until it ends and try again.', + 'codexSync.failed.title': 'Failed to import Codex sessions', + 'codexSync.failed.body': 'Failed to import Codex sessions.', + 'codexSync.failed.bodyWithReason': 'Import failed: {reason}', + 'codexSync.restart.tooltip': 'Restart Codex client', + 'codexSync.restart.title': 'Restart Codex client', + 'codexSync.restart.description': 'Restart Codex client now to refresh sessions?', + 'codexSync.restart.confirm': 'Restart', + 'codexSync.restart.confirming': 'Restarting…', + 'codexSync.restart.failed.title': 'Failed to restart Codex client', + 'codexSync.restart.failed.notFound': 'Failed to restart Codex client: not installed or not found', + 'codexSync.restart.failed.body': 'Failed to run the restart script.', + 'codexSync.restart.started.title': 'Restart started', + 'codexSync.restart.started.body': 'Codex client is restarting to refresh sessions.', + 'codexSync.duplicates.confirm.title': 'Duplicate sessions detected', + 'codexSync.duplicates.confirm.description': 'Duplicate sessions were detected. Merge them?', + 'codexSync.duplicates.confirm.confirm': 'Merge', + 'codexSync.duplicates.confirm.confirming': 'Merging…', + 'codexSync.duplicates.detect.failed.title': 'Failed to detect duplicate sessions', + 'codexSync.duplicates.detect.failed.body': 'Failed to detect duplicate sessions.', + 'codexSync.duplicates.merge.success.title': 'Duplicate sessions merged', + 'codexSync.duplicates.merge.success.body': 'Merged duplicates for the selected Codex sessions.', + 'codexSync.duplicates.merge.failed.title': 'Failed to merge duplicate sessions', + 'codexSync.duplicates.merge.failed.body': 'Failed to merge duplicate sessions.', // Session list 'session.item.path': 'path', @@ -71,6 +110,10 @@ export default { 'session.time.minutesAgo': '{n}m ago', 'session.time.hoursAgo': '{n}h ago', 'session.time.daysAgo': '{n}d ago', + 'session.time.importedFromCodex.justNow': 'just imported from Codex', + 'session.time.importedFromCodex.minutesAgo': 'imported from Codex {n}m ago', + 'session.time.importedFromCodex.hoursAgo': 'imported from Codex {n}h ago', + 'session.time.importedFromCodex.daysAgo': 'imported from Codex {n}d ago', // Session header 'session.title': 'Files', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index c1873a30..49e4ae94 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -53,6 +53,45 @@ export default { 'sessions.group.showMore': '再显示 {n} 个', 'sessions.group.showLess': '收起', 'sessions.group.new': '在此目录新建会话', + 'codexSync.tooltip': '从 Codex 导入会话到 Hapi', + 'codexSync.confirm.title': '导入 Codex 会话', + 'codexSync.confirm.description': '选择需要导入到 Hapi 的 Codex 会话', + // 中文注释:以下文案支撑导入弹窗的多选列表、加载态,以及右上角重启 Codex 客户端按钮提示。 + 'codexSync.confirm.selectAll': '全选', + 'codexSync.confirm.clearAll': '全取消', + 'codexSync.confirm.selectedCount': '已选择 {n} 个会话', + 'codexSync.confirm.empty': '未找到本地 Codex 会话', + 'codexSync.confirm.current': '当前关联', + 'codexSync.confirm.confirm': '导入', + 'codexSync.confirm.confirming': '导入中…', + 'codexSync.confirm.loading': '正在读取本地 Codex 会话…', + 'codexSync.success.title': '导入完成', + 'codexSync.success.body': '已导入 {n} 个 Codex 会话到 Hapi。', + 'codexSync.error.timeout': '执行超时', + 'codexSync.error.active': '当前会话仍处于活跃状态,请等待会话结束后重试', + 'codexSync.failed.title': '导入 Codex 会话失败', + 'codexSync.failed.body': '导入 Codex 会话失败。', + 'codexSync.failed.bodyWithReason': '导入失败:{reason}', + 'codexSync.restart.tooltip': '重启codex客户端', + 'codexSync.restart.title': '重启 Codex 客户端', + 'codexSync.restart.description': '是否立即重启 Codex 客户端以刷新会话?', + 'codexSync.restart.confirm': '重启', + 'codexSync.restart.confirming': '重启中…', + 'codexSync.restart.failed.title': '重启 Codex 客户端失败', + 'codexSync.restart.failed.notFound': '尝试重启codex客户端失败,未安装/找不到codex客户端', + 'codexSync.restart.failed.body': '重启脚本执行失败。', + 'codexSync.restart.started.title': '已发起重启', + 'codexSync.restart.started.body': 'Codex 客户端正在重启以刷新会话。', + 'codexSync.duplicates.confirm.title': '检测到重复会话', + 'codexSync.duplicates.confirm.description': '检测到重复会话,是否进行合并?', + 'codexSync.duplicates.confirm.confirm': '合并', + 'codexSync.duplicates.confirm.confirming': '合并中…', + 'codexSync.duplicates.detect.failed.title': '重复会话检测失败', + 'codexSync.duplicates.detect.failed.body': '重复会话检测失败。', + 'codexSync.duplicates.merge.success.title': '重复会话已合并', + 'codexSync.duplicates.merge.success.body': '已按本次选中的 Codex 会话完成去重合并。', + 'codexSync.duplicates.merge.failed.title': '重复会话合并失败', + 'codexSync.duplicates.merge.failed.body': '重复会话合并失败。', // Session list 'session.item.path': '路径', @@ -71,6 +110,10 @@ export default { 'session.time.minutesAgo': '{n} 分钟前', 'session.time.hoursAgo': '{n} 小时前', 'session.time.daysAgo': '{n} 天前', + 'session.time.importedFromCodex.justNow': '刚刚从codex客户端导入', + 'session.time.importedFromCodex.minutesAgo': '{n} 分钟前从codex客户端导入', + 'session.time.importedFromCodex.hoursAgo': '{n} 小时前从codex客户端导入', + 'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入', // Session header 'session.title': '文件', diff --git a/web/src/router.tsx b/web/src/router.tsx index dc463285..64da9fbf 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useQueryClient } from '@tanstack/react-query' import { Navigate, @@ -15,6 +15,8 @@ import { getScrollRestorationKey } from '@/lib/scrollRestorationKey' import { App } from '@/App' import { SessionChat } from '@/components/SessionChat' import { SessionList } from '@/components/SessionList' +import { CodexSessionSyncDialog } from '@/components/CodexSessionSyncDialog' +import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { NewSession } from '@/components/NewSession' import { WorkspaceBrowser } from '@/components/WorkspaceBrowser' import { LoadingState } from '@/components/LoadingState' @@ -36,7 +38,8 @@ import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' -import type { Machine } from '@/types/api' +import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' +import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' import FilesPage from '@/routes/sessions/files' import FilePage from '@/routes/sessions/file' import TerminalPage from '@/routes/sessions/terminal' @@ -81,6 +84,27 @@ function PlusIcon(props: { className?: string }) { ) } +function CodexImportIcon(props: { className?: string }) { + return ( + + {/* 中文注释:入口图标改成纯更新箭头,弱化“聊天”含义,避免用户误解成会话本身而不是导入动作。 */} + + + + ) +} + function FolderOpenIcon(props: { className?: string }) { return ( location.pathname }) const matchRoute = useMatchRoute() const { t } = useTranslation() + const { addToast } = useToast() const { sessions, isLoading, error, refetch } = useSessions(api) const { machines } = useMachines(api, true) + const [isSyncingCodexSession, setIsSyncingCodexSession] = useState(false) + const [codexSessions, setCodexSessions] = useState([]) + const [isLoadingCodexSessions, setIsLoadingCodexSessions] = useState(false) + const [isSyncConfirmOpen, setIsSyncConfirmOpen] = useState(false) + const [isRestartingCodexDesktop, setIsRestartingCodexDesktop] = useState(false) + const [pendingDuplicateSessionIds, setPendingDuplicateSessionIds] = useState([]) + const [duplicateSessionGroups, setDuplicateSessionGroups] = useState([]) + const [isDuplicateMergeConfirmOpen, setIsDuplicateMergeConfirmOpen] = useState(false) + const [isMergingDuplicateSessions, setIsMergingDuplicateSessions] = useState(false) const handleRefresh = useCallback(() => { void refetch() @@ -152,8 +187,8 @@ function SessionsPage() { const sessionMatch = matchRoute({ to: '/sessions/$sessionId', fuzzy: true }) const selectedSessionId = sessionMatch && sessionMatch.sessionId !== 'new' ? sessionMatch.sessionId : null const selectedSession = useMemo( - () => sessions.find((session) => session.id === selectedSessionId) ?? null, - [sessions, selectedSessionId] + () => selectedSessionId ? sessions.find((session) => session.id === selectedSessionId) ?? null : null, + [selectedSessionId, sessions] ) useEffect(() => { if (!selectedSessionId || !selectedSession) { @@ -161,6 +196,9 @@ function SessionsPage() { } markSessionSeen(selectedSessionId, selectedSession.updatedAt) }, [selectedSessionId, selectedSession?.updatedAt]) + const currentCodexSessionId = selectedSession?.metadata?.flavor === 'codex' + ? (selectedSession.metadata.agentSessionId ?? null) + : null const isSessionsIndex = pathname === '/sessions' || pathname === '/sessions/' const sidebar = useSidebarResize() const handleNewSessionInDirectory = useCallback((args: { machineId: string | null; directory: string }) => { @@ -172,8 +210,251 @@ function SessionsPage() { }) }, [navigate]) + const isCodexScriptTimeout = useCallback((message: string | null | undefined): boolean => { + const raw = (message ?? '').trim() + return /执行超时|timed\s*out|timeout/i.test(raw) + }, []) + + const normalizeCodexScriptError = useCallback((message: string | null | undefined, fallback: string): string => { + const raw = (message ?? '').trim() + if (!raw) return fallback + if (isCodexScriptTimeout(raw)) { + return t('codexSync.error.timeout') + } + if (/当前会话仍处于活跃状态,请等待会话结束后重试|Active Hapi process already has this Codex thread/i.test(raw)) { + return t('codexSync.error.active') + } + if (/未安装\/找不到codex客户端|unable to find codex launcher|找不到.*codex/i.test(raw)) { + return t('codexSync.restart.failed.notFound') + } + return raw + }, [isCodexScriptTimeout, t]) + + const formatCodexSyncFailureBody = useCallback((reason: string): string => { + if ( + reason === t('codexSync.error.timeout') || + reason === t('codexSync.error.active') || + reason === t('codexSync.restart.failed.notFound') + ) { + return reason + } + return t('codexSync.failed.bodyWithReason', { reason }) + }, [t]) + + const closeDuplicateMergeDialog = useCallback(() => { + // 中文注释:重复会话确认框关闭时一并清空“本次选中导入”的上下文,确保后续检测不会误用上一轮的 codexSessionId。 + setIsDuplicateMergeConfirmOpen(false) + setPendingDuplicateSessionIds([]) + setDuplicateSessionGroups([]) + }, []) + + const handleRestartCodexDesktop = useCallback(async () => { + setIsRestartingCodexDesktop(true) + try { + const status = await api.getCodexDesktopStatus() + if (!status.codexClientAvailable) { + throw new Error(t('codexSync.restart.failed.notFound')) + } + + const result = await api.restartCodexDesktop() + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.restart.failed.body'))) + } + addToast({ + title: t('codexSync.restart.started.title'), + body: t('codexSync.restart.started.body'), + sessionId: '', + url: '' + }) + } catch (error) { + addToast({ + title: t('codexSync.restart.failed.title'), + body: normalizeCodexScriptError( + error instanceof Error ? error.message : null, + t('codexSync.restart.failed.body') + ), + sessionId: '', + url: '' + }) + } finally { + setIsRestartingCodexDesktop(false) + } + }, [addToast, api, normalizeCodexScriptError, t]) + + const handleMergeDuplicateSessions = useCallback(async () => { + if (isMergingDuplicateSessions || pendingDuplicateSessionIds.length === 0) return + + setIsMergingDuplicateSessions(true) + try { + const result = await api.mergeCodexDuplicateSessions({ sessionIds: pendingDuplicateSessionIds }) + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.duplicates.merge.failed.body'))) + } + + addToast({ + title: t('codexSync.duplicates.merge.success.title'), + body: t('codexSync.duplicates.merge.success.body'), + sessionId: '', + url: '' + }) + + const redirectTarget = selectedSessionId + ? result.merged.find((group) => group.removedSessionIds?.includes(selectedSessionId)) + : undefined + + closeDuplicateMergeDialog() + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), + selectedSessionId + ? queryClient.invalidateQueries({ queryKey: queryKeys.session(selectedSessionId) }) + : Promise.resolve(), + selectedSessionId + ? queryClient.invalidateQueries({ queryKey: queryKeys.messages(selectedSessionId) }) + : Promise.resolve() + ]) + await refetch() + + if (redirectTarget?.canonicalSessionId) { + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: redirectTarget.canonicalSessionId } + }) + } + } catch (error) { + addToast({ + title: t('codexSync.duplicates.merge.failed.title'), + body: normalizeCodexScriptError( + error instanceof Error ? error.message : null, + t('codexSync.duplicates.merge.failed.body') + ), + sessionId: '', + url: '' + }) + throw error + } finally { + setIsMergingDuplicateSessions(false) + } + }, [ + addToast, + api, + closeDuplicateMergeDialog, + isMergingDuplicateSessions, + navigate, + normalizeCodexScriptError, + pendingDuplicateSessionIds, + queryClient, + refetch, + selectedSessionId, + t + ]) + + const openCodexImportDialog = useCallback(async () => { + if (isLoadingCodexSessions) return + + setIsSyncConfirmOpen(true) + setIsLoadingCodexSessions(true) + try { + const result = await api.getCodexSessions() + setCodexSessions(result.sessions) + } catch (error) { + setCodexSessions([]) + const reason = normalizeCodexScriptError( + error instanceof Error ? error.message : null, + t('dialog.error.default') + ) + addToast({ + title: t('codexSync.failed.title'), + body: formatCodexSyncFailureBody(reason), + sessionId: '', + url: '' + }) + } finally { + setIsLoadingCodexSessions(false) + } + }, [addToast, api, formatCodexSyncFailureBody, isLoadingCodexSessions, normalizeCodexScriptError, t]) + + const handleImportCodexSessions = useCallback(async (sessionIds: string[]) => { + if (isSyncingCodexSession || isLoadingCodexSessions) return + + setIsSyncingCodexSession(true) + try { + // 中文注释:弹窗提交的是本地 Codex thread ID;后端会直接读取这些 transcript 并导入到 Hapi。 + const result = await api.syncCodexSession({ sessionIds }) + if (!result.success) { + throw new Error(normalizeCodexScriptError(result.error, t('codexSync.failed.body'))) + } + + addToast({ + title: t('codexSync.success.title'), + body: t('codexSync.success.body', { n: result.syncedCount ?? sessionIds.length }), + sessionId: '', + url: '' + }) + // 中文注释:导入成功后先在浏览器侧记住这些 Codex thread 的导入时间,供左侧会话列表显示特殊时间文案。 + markCodexSessionsImported(sessionIds) + setIsSyncConfirmOpen(false) + await refetch() + + setPendingDuplicateSessionIds([]) + setDuplicateSessionGroups([]) + setIsDuplicateMergeConfirmOpen(false) + try { + // 中文注释:重复会话检测严格限定在这次用户勾选导入的 codexSessionId 范围内;未勾选的其它会话不参与检测,也不弹合并提示。 + const duplicateResult = await api.getCodexDuplicateSessions({ sessionIds }) + if (!duplicateResult.success) { + throw new Error(normalizeCodexScriptError( + duplicateResult.error, + t('codexSync.duplicates.detect.failed.body') + )) + } + + if (duplicateResult.duplicates.length > 0) { + setPendingDuplicateSessionIds(sessionIds) + setDuplicateSessionGroups(duplicateResult.duplicates) + setIsDuplicateMergeConfirmOpen(true) + } + } catch (duplicateError) { + addToast({ + title: t('codexSync.duplicates.detect.failed.title'), + body: normalizeCodexScriptError( + duplicateError instanceof Error ? duplicateError.message : null, + t('codexSync.duplicates.detect.failed.body') + ), + sessionId: '', + url: '' + }) + } + } catch (syncError) { + const reason = normalizeCodexScriptError( + syncError instanceof Error ? syncError.message : null, + t('dialog.error.default') + ) + addToast({ + title: t('codexSync.failed.title'), + body: formatCodexSyncFailureBody(reason), + sessionId: '', + url: '' + }) + } finally { + setIsSyncingCodexSession(false) + } + }, [ + addToast, + api, + formatCodexSyncFailureBody, + isLoadingCodexSessions, + isSyncingCodexSession, + normalizeCodexScriptError, + refetch, + setDuplicateSessionGroups, + setIsDuplicateMergeConfirmOpen, + setPendingDuplicateSessionIds, + t + ]) + return ( -
+ <> +
+
-
+
+ {/* 中文注释:这里展示的是本地 Codex transcript 列表;默认尝试勾选当前 Hapi 会话关联的 Codex thread。 */} + setIsSyncConfirmOpen(false)} + sessions={codexSessions} + currentCodexSessionId={currentCodexSessionId} + onConfirm={handleImportCodexSessions} + onRestartCodexDesktop={handleRestartCodexDesktop} + isPending={isSyncingCodexSession} + isRestartingCodexDesktop={isRestartingCodexDesktop} + isLoading={isLoadingCodexSessions} + /> + 0} + onClose={closeDuplicateMergeDialog} + title={t('codexSync.duplicates.confirm.title')} + description={t('codexSync.duplicates.confirm.description')} + confirmLabel={t('codexSync.duplicates.confirm.confirm')} + confirmingLabel={t('codexSync.duplicates.confirm.confirming')} + onConfirm={handleMergeDuplicateSessions} + isPending={isMergingDuplicateSessions} + /> + ) } @@ -292,6 +607,8 @@ function SessionPage() { isSessionThinking: session?.thinking ?? false, onSuccess: (sentSessionId) => { clearDraftsAfterSend(sentSessionId, sessionId) + // 中文注释:一旦用户已经在 Hapi 内继续这个 Codex 会话,就清除“刚从 Codex 导入”的标记。 + clearCodexImportedSession(session?.metadata?.codexSessionId) }, resolveSessionId: async (currentSessionId) => { if (!api || !session || session.active) { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 5552b521..171cc614 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -142,6 +142,75 @@ export type PushVapidPublicKeyResponse = { publicKey: string } +export type CodexDesktopScriptResponse = { + success: boolean + message?: string + pid?: number + command?: string + script?: string + cwd?: string + output?: string + error?: string + codexDesktopRunning?: boolean + codexClientAvailable?: boolean + // 中文注释:多选导入时返回实际处理完成的 Codex 会话数量,用于前端提示本次导入条数。 + syncedCount?: number + // 中文注释:这里存放本次导入对应的 Codex thread ID 列表,方便日志和排查 direct import 结果。 + sessionIds?: string[] +} + +export type CodexLocalSessionSummary = { + id: string + title: string + lastUserMessage?: string | null + cwd?: string | null + file: string + modifiedAt: number + originator?: string | null + cliVersion?: string | null +} + +export type CodexLocalSessionsResponse = { + success: true + sessions: CodexLocalSessionSummary[] +} + +export type CodexDesktopSyncRequest = { + // 中文注释:前端弹窗直接提交 Codex thread ID,后端会按这些 transcript 直接导入到 Hapi。 + sessionIds: string[] +} + +export type CodexDesktopStatusResponse = { + success: true + codexDesktopRunning: boolean + codexClientAvailable: boolean +} + +export type CodexDuplicateSessionGroup = { + codexSessionId: string + hapiSessionIds: string[] + canonicalSessionId?: string + removedSessionIds?: string[] +} + +export type CodexDuplicateSessionsResponse = { + success: true + // 中文注释:这里只返回本次选中导入的 codexSessionId 中检测出来的重复会话,不包含未勾选的其它会话。 + duplicates: CodexDuplicateSessionGroup[] +} | { + success: false + error: string +} + +export type CodexMergeDuplicateSessionsResponse = { + success: true + merged: CodexDuplicateSessionGroup[] + mergedCount: number +} | { + success: false + error: string +} + export type VisibilityPayload = { subscriptionId: string visibility: 'visible' | 'hidden'