feat(codex): import and resume sessions from runners (#1088)

* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
This commit is contained in:
NightWatcher314
2026-07-19 14:14:42 +08:00
committed by GitHub
co-authored by syy
parent 651c83a1d9
commit 64834467e3
42 changed files with 2536 additions and 133 deletions
+5
View File
@@ -12,6 +12,7 @@ import {
setSessionModel,
setSessionModelReasoningEffort,
setSessionServiceTier,
setSessionActive,
setSessionTeamState,
setSessionTodos,
touchSessionUpdatedAt,
@@ -87,6 +88,10 @@ export class SessionStore {
return setSessionServiceTier(this.db, id, serviceTier, namespace, options)
}
setSessionActive(id: string, active: boolean, activeAt: number, namespace: string): boolean {
return setSessionActive(this.db, id, active, activeAt, namespace)
}
touchSessionUpdatedAt(id: string, updatedAt: number, namespace: string): boolean {
return touchSessionUpdatedAt(this.db, id, updatedAt, namespace)
}
+32
View File
@@ -539,6 +539,38 @@ export function setSessionEffort(
}
}
export function setSessionActive(
db: Database,
id: string,
active: boolean,
activeAt: number,
namespace: string
): boolean {
try {
const result = db.prepare(`
UPDATE sessions
SET active = @active,
active_at = CASE
WHEN active_at IS NULL OR active_at < @active_at THEN @active_at
ELSE active_at
END,
seq = seq + 1
WHERE id = @id
AND namespace = @namespace
AND (active IS NOT @active OR active_at IS NULL OR active_at < @active_at)
`).run({
id,
namespace,
active: active ? 1 : 0,
active_at: activeAt
})
return result.changes === 1
} catch {
return false
}
}
export function touchSessionUpdatedAt(
db: Database,
id: string,
+22 -3
View File
@@ -1,6 +1,10 @@
import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
import { CursorChatStoreStatusSchema } from '@hapi/protocol/apiTypes'
import {
ArchiveCodexSessionRpcResponseSchema,
CursorChatStoreStatusSchema,
ListCodexSessionsRpcResponseSchema
} from '@hapi/protocol/apiTypes'
import type {
CodexModelSummary,
CodexModelsResponse,
@@ -15,6 +19,8 @@ import type {
GrokModelsResponse,
GrokReasoningEffortResponse,
ListDirectoryResponse,
ListCodexSessionsRpcResponse,
ArchiveCodexSessionRpcResponse,
OpencodeModelsResponse,
OpencodeModelSummary,
OpencodeReasoningEffortResponse,
@@ -59,6 +65,8 @@ export type RpcListDirectoryResponse = ListDirectoryResponse
export type RpcPathExistsResponse = PathExistsResponse
export type RpcCodexModel = CodexModelSummary
export type RpcListCodexModelsResponse = CodexModelsResponse
export type RpcListCodexSessionsResponse = ListCodexSessionsRpcResponse
export type RpcArchiveCodexSessionResponse = ArchiveCodexSessionRpcResponse
export type RpcCursorModel = CursorModelSummary
export type RpcListCursorModelsResponse = CursorModelsResponse
export type RpcCursorChatStoreStatus = CursorChatStoreStatus
@@ -146,13 +154,14 @@ export class RpcGateway {
resumeSessionId?: string,
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: string
serviceTier?: string,
existingSessionId?: string
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
try {
const result = await this.machineRpc(
machineId,
RPC_METHODS.SpawnHappySession,
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier }
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, sessionId: existingSessionId }
)
if (result && typeof result === 'object') {
const obj = result as Record<string, unknown>
@@ -287,6 +296,16 @@ export class RpcGateway {
return await this.machineRpc(machineId, RPC_METHODS.ListCodexModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCodexModelsResponse
}
async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]): Promise<RpcListCodexSessionsResponse> {
const result = await this.machineRpc(machineId, RPC_METHODS.ListCodexSessions, { cwd: cwd ?? null, sessionIds }, MODEL_LIST_RPC_TIMEOUT_MS)
return ListCodexSessionsRpcResponseSchema.parse(result)
}
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
const result = await this.machineRpc(machineId, RPC_METHODS.ArchiveCodexSession, { sessionId }, MODEL_LIST_RPC_TIMEOUT_MS)
return ArchiveCodexSessionRpcResponseSchema.parse(result)
}
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse
}
+28
View File
@@ -186,6 +186,32 @@ export class SessionCache {
}
}
markSessionActive(sessionId: string, time: number = Date.now()): void {
const t = clampAliveTime(time) ?? Date.now()
const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId)
if (!session) return
const wasActive = session.active
session.active = true
session.activeAt = Math.max(session.activeAt, t)
this.lastBroadcastAtBySessionId.set(session.id, Date.now())
this.publisher.emit({
type: 'session-updated',
sessionId: session.id,
namespace: session.namespace,
data: {
active: true,
activeAt: session.activeAt,
thinking: session.thinking
} satisfies SessionPatch
})
if (!wasActive) {
this.refreshSession(sessionId)
}
}
handleSessionAlive(payload: {
sid: string
time: number
@@ -401,6 +427,7 @@ export class SessionCache {
}
session.active = false
this.store.sessions.setSessionActive(session.id, false, t, session.namespace)
session.thinking = false
session.thinkingAt = t
session.backgroundTaskCount = 0
@@ -421,6 +448,7 @@ export class SessionCache {
if (!session.active) continue
if (now - session.activeAt <= sessionTimeoutMs) continue
session.active = false
this.store.sessions.setSessionActive(session.id, false, now, session.namespace)
session.thinking = false
this.pendingThinkingUntilBySessionId.delete(session.id)
expired.push(session.id)
+112
View File
@@ -715,6 +715,58 @@ describe('session model', () => {
}
})
it('marks a resumed session active in hub cache before returning success without persisting runtime active state', async () => {
const store = new Store(':memory:')
const events: unknown[] = []
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast(event: unknown) { events.push(event) } } as never
)
try {
const session = engine.getOrCreateSession(
'session-resume-active-state',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1'
},
null,
'default',
'gpt-5.4'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
;(engine as any).rpcGateway.spawnSession = async () => ({ type: 'success', sessionId: session.id })
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default')
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(engine.getSession(session.id)?.active).toBe(true)
// 中文注释:active=true 是运行时状态,不能跨 Hub 重启持久化;否则旧会话会在重启后假在线。
expect(store.sessions.getSession(session.id)?.active).toBe(false)
expect(events.some((event) => {
const record = event as { type?: string; sessionId?: string; data?: { active?: boolean } }
return record.type === 'session-updated'
&& record.sessionId === session.id
&& record.data?.active === true
})).toBe(true)
} finally {
engine.stop()
}
})
it('passes resume session ID to rpc gateway when resuming claude session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
@@ -965,6 +1017,66 @@ describe('session model', () => {
}
})
it('does not let stale default resume option override persisted Codex yolo', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'session-codex-yolo-resume',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'codex',
codexSessionId: 'codex-thread-1',
preferredPermissionMode: 'yolo'
},
null,
'default',
'gpt-5'
)
engine.getOrCreateMachine(
'machine-1',
{ host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' },
null,
'default'
)
engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() })
let capturedPermissionMode: string | undefined
;(engine as any).rpcGateway.spawnSession = async (
_machineId: string,
_directory: string,
_agent: string,
_model?: string,
_modelReasoningEffort?: string,
_yolo?: boolean,
_sessionType?: string,
_worktreeName?: string,
_resumeSessionId?: string,
_effort?: string,
permissionMode?: string
) => {
capturedPermissionMode = permissionMode
return { type: 'success', sessionId: session.id }
}
;(engine as any).waitForSessionActive = async () => true
const result = await engine.resumeSession(session.id, 'default', { permissionMode: 'default' })
expect(result).toEqual({ type: 'success', sessionId: session.id })
expect(capturedPermissionMode).toBe('yolo')
} finally {
engine.stop()
}
})
it('passes the cached permissionMode when respawning a resumed session', async () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
+22 -6
View File
@@ -30,6 +30,7 @@ import {
type RpcGeneratedImageResponse,
type RpcListDirectoryResponse,
type RpcListCodexModelsResponse,
type RpcArchiveCodexSessionResponse,
type RpcListCursorModelsResponse,
type RpcListOpencodeModelsResponse,
type RpcListGrokModelsResponse,
@@ -807,7 +808,8 @@ export class SyncEngine {
resumeSessionId?: string,
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: string
serviceTier?: string,
existingSessionId?: string
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
return await this.rpcGateway.spawnSession(
machineId,
@@ -821,7 +823,8 @@ export class SyncEngine {
resumeSessionId,
effort,
permissionMode,
serviceTier
serviceTier,
existingSessionId
)
}
@@ -1274,9 +1277,12 @@ export class SyncEngine {
}
}
const preferredPermissionMode = opts?.permissionMode
?? session.permissionMode
?? session.metadata?.preferredPermissionMode
const metadataPermissionMode = session.metadata?.preferredPermissionMode
const preferredPermissionMode = metadataPermissionMode === 'yolo' && opts?.permissionMode === 'default'
? metadataPermissionMode
: opts?.permissionMode
?? session.permissionMode
?? metadataPermissionMode
const spawnResult = await this.rpcGateway.spawnSession(
targetMachine.id,
directory,
@@ -1289,7 +1295,8 @@ export class SyncEngine {
resumeToken,
session.effort ?? undefined,
preferredPermissionMode,
session.serviceTier ?? undefined
session.serviceTier ?? undefined,
access.sessionId
)
if (spawnResult.type !== 'success') {
@@ -1332,6 +1339,7 @@ export class SyncEngine {
}
}
this.sessionCache.markSessionActive(spawnResult.sessionId)
return { type: 'success', sessionId: spawnResult.sessionId }
}
@@ -1690,6 +1698,14 @@ export class SyncEngine {
return await this.rpcGateway.listCodexModelsForMachine(machineId)
}
async listCodexSessionsForMachine(machineId: string, cwd?: string | null, sessionIds?: string[]) {
return await this.rpcGateway.listCodexSessionsForMachine(machineId, cwd, sessionIds)
}
async archiveCodexSessionForMachine(machineId: string, sessionId: string): Promise<RpcArchiveCodexSessionResponse> {
return await this.rpcGateway.archiveCodexSessionForMachine(machineId, sessionId)
}
async listCursorModelsForSession(sessionId: string): Promise<RpcListCursorModelsResponse> {
return await this.rpcGateway.listCursorModelsForSession(sessionId)
}
+115 -27
View File
@@ -433,6 +433,51 @@ describe('Codex Desktop import routes', () => {
}
})
it('updates an existing forked import when syncing the original Codex session id', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-source-test-'))
const store = new Store(':memory:')
const codexSessionId = '12121212-1212-4121-8121-121212121212'
process.env.CODEX_HOME = codexHome
try {
createTranscript(codexHome, codexSessionId)
const first = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
namespace: 'default',
getSyncEngine: () => null
})
expect(first.success).toBe(true)
const imported = store.sessions.getSessionsByNamespace('default')[0]
expect(imported).toBeDefined()
store.sessions.updateSessionMetadata(imported.id, {
...(imported.metadata ?? {}),
codexSessionId: 'fork-session-id',
codexSourceSessionId: codexSessionId
}, imported.metadataVersion, 'default')
const second = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
namespace: 'default',
getSyncEngine: () => null
})
expect(second.success).toBe(true)
const sessions = store.sessions.getSessionsByNamespace('default')
expect(sessions).toHaveLength(1)
expect(sessions[0]?.metadata).toMatchObject({
codexSessionId: 'fork-session-id',
codexSourceSessionId: codexSessionId
})
} finally {
store.close()
rmSync(codexHome, { recursive: true, force: true })
}
})
it('deduplicates mirrored event_msg and response_item user messages', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-mirror-test-'))
const store = new Store(':memory:')
@@ -727,12 +772,6 @@ describe('Codex Desktop import routes', () => {
}
])
const app = createRoutesApp('default')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(200)
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('new thread title')
const result = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
@@ -762,12 +801,6 @@ describe('Codex Desktop import routes', () => {
try {
createTranscript(codexHome, codexSessionId)
const app = createRoutesApp('default')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(200)
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
const result = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
@@ -804,12 +837,6 @@ describe('Codex Desktop import routes', () => {
}
])
const app = createRoutesApp('default')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(200)
const body = await response.json() as { sessions: Array<{ id: string; title: string }> }
expect(body.sessions.find((session) => session.id === codexSessionId)?.title).toBe('normal user message')
const result = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
@@ -925,7 +952,7 @@ describe('Codex Desktop import routes', () => {
}
})
it('keeps an existing machineId when updating an imported transcript', async () => {
it('does not append a Runner transcript to a session bound to another machine', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-existing-test-'))
const store = new Store(':memory:')
const codexSessionId = '55555555-5555-4555-8555-555555555555'
@@ -947,15 +974,21 @@ describe('Codex Desktop import routes', () => {
codexSessionIds: [codexSessionId],
store,
namespace: 'default',
getSyncEngine: () => engine
getSyncEngine: () => engine,
machineId: 'machine-new'
})
expect(result.success).toBe(true)
const session = store.sessions.getSessionsByNamespace('default')[0]
expect(session.metadata).toMatchObject({
path: '/home/user/workspace/project',
machineId: 'machine-existing'
})
const sessions = store.sessions.getSessionsByNamespace('default')
expect(sessions).toHaveLength(2)
expect(sessions.some((session) => (
(session.metadata as Record<string, unknown> | null)?.path === '/home/user/workspace/project'
&& (session.metadata as Record<string, unknown> | null)?.machineId === 'machine-new'
))).toBe(true)
expect(sessions.some((session) => (
(session.metadata as Record<string, unknown> | null)?.path === '/home/user/workspace/project'
&& (session.metadata as Record<string, unknown> | null)?.machineId === 'machine-existing'
))).toBe(true)
} finally {
store.close()
rmSync(codexHome, { recursive: true, force: true })
@@ -981,13 +1014,68 @@ describe('Codex Desktop import routes', () => {
const app = createRoutesApp('default')
const response = await app.request('/api/codex/sessions')
expect(response.status).toBe(200)
expect(response.status).toBe(503)
expect(await response.json()).toEqual({
success: true,
success: false,
error: 'No online machine available for Codex history import',
sessions: []
})
} finally {
rmSync(codexHome, { recursive: true, force: true })
}
})
it('does not fall back to another Runner when the requested machine is offline', async () => {
const store = new Store(':memory:')
let listCalls = 0
const engine = {
getOnlineMachinesByNamespace: () => [createMachine('online-machine', ['/tmp'])],
listCodexSessionsForMachine: async () => {
listCalls += 1
return { success: true, sessions: [] }
}
} as unknown as SyncEngine
const app = new Hono<WebAppEnv>()
app.use('*', async (c, next) => {
c.set('namespace', 'default')
await next()
})
app.route('/api', createCodexDesktopRoutes({ store, getSyncEngine: () => engine }))
try {
const response = await app.request('/api/codex/sessions?machineId=offline-machine')
expect(response.status).toBe(503)
expect(listCalls).toBe(0)
} finally {
store.close()
}
})
it('treats source and fork ids as the same duplicate-sessions group', async () => {
const app = new Hono<WebAppEnv>()
app.use('*', async (c, next) => {
c.set('namespace', 'default')
await next()
})
const store = new Store(':memory:')
const forkSession = store.sessions.getOrCreateSession('fork-session-id', { codexSessionId: 'fork-session-id', codexSourceSessionId: 'original-session-id' }, {}, 'default')
const dupSession = store.sessions.getOrCreateSession('dup-session-id', { codexSessionId: 'original-session-id' }, {}, 'default')
app.route('/api', createCodexDesktopRoutes({
store,
getSyncEngine: () => null
}))
const response = await app.request('/api/codex/duplicate-sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionIds: ['original-session-id'] })
})
expect(response.status).toBe(200)
const body = await response.json() as { success: true; duplicates: Array<{ codexSessionId: string; hapiSessionIds: string[] }> }
expect(body.success).toBe(true)
expect(body.duplicates).toHaveLength(1)
expect(body.duplicates[0]?.codexSessionId).toBe('original-session-id')
expect(body.duplicates[0]?.hapiSessionIds.sort()).toEqual([dupSession.id, forkSession.id].sort())
})
})
+240 -34
View File
@@ -29,6 +29,7 @@ type ScriptLaunchResponse = {
codexClientAvailable?: boolean
syncedCount?: number
sessionIds?: string[]
hapiSessionIds?: string[]
} | {
success: false
error: string
@@ -39,6 +40,7 @@ type ScriptLaunchResponse = {
codexClientAvailable?: boolean
syncedCount?: number
sessionIds?: string[]
hapiSessionIds?: string[]
}
type CodexDesktopStatus = {
@@ -66,6 +68,12 @@ type CodexLocalSessionSummary = {
type CodexLocalSessionsResponse = {
success: true
sessions: CodexLocalSessionSummary[]
machineId?: string
} | {
success: false
error: string
sessions: []
machineId?: string
}
type CodexImportedMessageContent = {
@@ -102,6 +110,7 @@ type CodexSessionIndexTitle = {
threadName: string
updatedAt: string
}
type RemoteCodexSession = CodexTranscriptImportData
type ImportCandidate = {
sessionId: string
@@ -117,6 +126,11 @@ type ImportTargetSelection = {
type SyncSessionRequestParseResult = {
sessionIds: string[]
cwd?: string | null
machineId?: string | null
model?: string | null
modelReasoningEffort?: string | null
yolo?: boolean
error?: string
}
@@ -894,10 +908,65 @@ function resolveImportMachineId(
return machineIds.length === 1 ? machineIds[0] : undefined
}
function resolveCodexImportMachineId(
cwd: string | null | undefined,
namespace: string,
engine: SyncEngine | null,
requestedMachineId?: string | null
): string | null {
if (!engine) return null
const onlineMachines = engine.getOnlineMachinesByNamespace(namespace)
if (requestedMachineId) {
return onlineMachines.some((machine) => machine.id === requestedMachineId)
? requestedMachineId
: null
}
if (cwd) {
const resolved = resolveImportMachineId(cwd, namespace, engine)
if (resolved) return resolved
}
return onlineMachines.length === 1 ? onlineMachines[0].id : null
}
function asRemoteCodexSessions(value: unknown, requireMessages: boolean): RemoteCodexSession[] {
if (!Array.isArray(value)) return []
return value.filter((session): session is RemoteCodexSession => {
const record = asRecord(session)
return typeof record?.id === 'string'
&& typeof record.title === 'string'
&& typeof record.file === 'string'
&& typeof record.modifiedAt === 'number'
&& (!requireMessages || Array.isArray(record.messages))
})
}
async function listCodexSessionsViaMachine(options: {
engine: SyncEngine | null
namespace: string
cwd?: string | null
machineId?: string | null
sessionIds?: string[]
}): Promise<{ sessions: RemoteCodexSession[]; machineId?: string; error?: string }> {
const machineId = resolveCodexImportMachineId(options.cwd, options.namespace, options.engine, options.machineId)
if (!machineId || !options.engine) {
return { sessions: [], error: 'No online machine available for Codex history import' }
}
const result = await options.engine.listCodexSessionsForMachine(machineId, options.cwd, options.sessionIds)
if (!result || typeof result !== 'object') {
return { sessions: [], machineId, error: 'Unexpected Codex sessions RPC response' }
}
if ((result as { success?: unknown }).success !== true) {
return { sessions: [], machineId, error: typeof (result as { error?: unknown }).error === 'string' ? (result as { error: string }).error : 'Failed to list local Codex sessions' }
}
return { sessions: asRemoteCodexSessions((result as { sessions?: unknown }).sessions, Boolean(options.sessionIds?.length)), machineId }
}
function buildImportedSessionMetadata(
data: CodexTranscriptImportData,
existingMetadata?: Record<string, unknown> | null,
resolvedMachineId?: string
resolvedMachineId?: string,
permissionMode?: string
): Record<string, unknown> {
const now = Date.now()
const path = data.cwd ?? (typeof existingMetadata?.path === 'string' ? existingMetadata.path : dirname(data.file))
@@ -907,6 +976,9 @@ function buildImportedSessionMetadata(
const machineId = typeof existingMetadata?.machineId === 'string'
? existingMetadata.machineId
: resolvedMachineId
const currentCodexSessionId = typeof existingMetadata?.codexSessionId === 'string'
? existingMetadata.codexSessionId
: data.id
return {
...(existingMetadata ?? {}),
@@ -921,7 +993,11 @@ function buildImportedSessionMetadata(
}
: existingMetadata?.summary,
flavor: 'codex',
codexSessionId: data.id,
codexSessionId: currentCodexSessionId,
codexSourceSessionId: typeof existingMetadata?.codexSourceSessionId === 'string'
? existingMetadata.codexSourceSessionId
: data.id,
...(permissionMode ? { preferredPermissionMode: permissionMode } : {}),
...(machineId ? { machineId } : {}),
lifecycleState: typeof existingMetadata?.lifecycleState === 'string'
? existingMetadata.lifecycleState
@@ -953,6 +1029,10 @@ function stableSerialize(value: unknown): string {
return JSON.stringify(value)
}
function normalizeComparableText(value: string): string {
return value.replace(/\s+$/u, '')
}
function normalizeComparableAgentData(value: unknown): unknown {
const record = asRecord(value)
if (!record) {
@@ -979,7 +1059,7 @@ function normalizeComparableContent(content: unknown): string | null {
}
return stableSerialize({
role: 'user',
text: body.text
text: normalizeComparableText(body.text)
})
}
@@ -1025,20 +1105,30 @@ function collectImportCandidates(
}))
}
function getCodexImportIds(metadata: Record<string, unknown> | null | undefined): string[] {
return [metadata?.codexSessionId, metadata?.codexSourceSessionId]
.filter((id): id is string => typeof id === 'string' && id.length > 0)
}
function selectImportTargetSession(
store: Store,
candidates: ImportCandidate[],
codexSessionId: string,
importedComparableMessages: string[]
importedComparableMessages: string[],
sourceMachineId?: string | null
): ImportTargetSelection {
const relatedCandidates = candidates
.filter((candidate) => candidate.metadata?.codexSessionId === codexSessionId)
.filter((candidate) => (
candidate.metadata?.codexSessionId === codexSessionId
|| candidate.metadata?.codexSourceSessionId === codexSessionId
))
.filter((candidate) => (
!sourceMachineId
|| typeof candidate.metadata?.machineId !== 'string'
|| candidate.metadata.machineId === sourceMachineId
))
.sort((a, b) => b.updatedAt - a.updatedAt)
if (relatedCandidates.some((candidate) => candidate.active)) {
throw new Error('当前会话仍处于活跃状态,请等待会话结束后重试')
}
let bestSessionId: string | null = null
let bestPrefixCount = -1
@@ -1088,18 +1178,17 @@ function listDuplicateCodexSessionGroups(
const groups = new Map<string, ImportCandidate[]>()
for (const candidate of collectImportCandidates(store, namespace, getSyncEngine)) {
const codexSessionId = typeof candidate.metadata?.codexSessionId === 'string'
? candidate.metadata.codexSessionId
: null
if (!codexSessionId || !requestedSessionIds.has(codexSessionId)) {
continue
}
for (const codexSessionId of getCodexImportIds(candidate.metadata)) {
if (!requestedSessionIds.has(codexSessionId)) {
continue
}
const existing = groups.get(codexSessionId)
if (existing) {
existing.push(candidate)
} else {
groups.set(codexSessionId, [candidate])
const existing = groups.get(codexSessionId)
if (existing) {
existing.push(candidate)
} else {
groups.set(codexSessionId, [candidate])
}
}
}
@@ -1590,7 +1679,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
return { sessionIds: [] }
}
const rawSessionIds = (body as { sessionIds?: unknown }).sessionIds
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; yolo?: unknown }
const rawSessionIds = bodyRecord.sessionIds
if (!Array.isArray(rawSessionIds)) {
return { sessionIds: [], error: 'Invalid sessionIds' }
}
@@ -1606,8 +1696,18 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
}
}
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort')
// 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。
return { sessionIds: Array.from(new Set(sessionIds)) }
return {
sessionIds: Array.from(new Set(sessionIds)),
cwd: typeof bodyRecord.cwd === 'string' && bodyRecord.cwd.trim() ? bodyRecord.cwd.trim() : null,
machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null,
model: hasModel ? (typeof bodyRecord.model === 'string' && bodyRecord.model.trim() ? bodyRecord.model.trim() : null) : undefined,
modelReasoningEffort: hasModelReasoningEffort ? (typeof bodyRecord.modelReasoningEffort === 'string' && bodyRecord.modelReasoningEffort.trim() ? bodyRecord.modelReasoningEffort.trim() : null) : undefined,
yolo: bodyRecord.yolo === true
}
}
function combineSyncOutputs(results: ScriptLaunchResponse[]): string | undefined {
@@ -1645,6 +1745,12 @@ function createImportErrorResponse(
}
}
function parseImportedHapiSessionId(output?: string): string | null {
if (!output) return null
const match = /^Hapi session:\s*(.+)$/m.exec(output)
return match?.[1]?.trim() || null
}
function createImportSuccessResponse(
codexSessionIds: string[],
results: ScriptLaunchResponse[]
@@ -1663,16 +1769,21 @@ function createImportSuccessResponse(
cwd: workspace,
output: combineSyncOutputs(results),
sessionIds: codexSessionIds,
hapiSessionIds: results.map((result) => parseImportedHapiSessionId(result.output)).filter((id): id is string => Boolean(id)),
syncedCount: results.length
}
}
function importSingleCodexSession(options: {
codexSessionId: string
localSessionsById: Map<string, CodexLocalSessionSummary>
localSessionsById: Map<string, CodexLocalSessionSummary | RemoteCodexSession>
store: Store
namespace: string
getSyncEngine?: () => SyncEngine | null
model?: string | null
modelReasoningEffort?: string | null
yolo?: boolean
machineId?: string | null
}): ScriptLaunchResponse {
const summary = options.localSessionsById.get(options.codexSessionId)
if (!summary) {
@@ -1682,7 +1793,9 @@ function importSingleCodexSession(options: {
}
}
const transcript = parseCodexTranscriptImportData(summary)
const transcript = 'messages' in summary && Array.isArray((summary as RemoteCodexSession).messages)
? summary as RemoteCodexSession
: parseCodexTranscriptImportData(summary)
if (!transcript) {
return {
...createImportErrorResponse([options.codexSessionId], `Failed to parse Codex transcript: ${summary.file}`),
@@ -1707,14 +1820,16 @@ function importSingleCodexSession(options: {
options.store,
candidates,
options.codexSessionId,
importedComparableMessages
importedComparableMessages,
options.machineId
)
const engine = options.getSyncEngine?.() ?? null
const existingStored = target.sessionId ? options.store.sessions.getSessionByNamespace(target.sessionId, options.namespace) : null
const metadata = buildImportedSessionMetadata(
transcript,
asRecord(existingStored?.metadata),
resolveImportMachineId(transcript.cwd, options.namespace, engine)
options.machineId ?? resolveImportMachineId(transcript.cwd, options.namespace, engine) ?? undefined,
options.yolo ? 'yolo' : undefined
)
let sessionId = existingStored?.id ?? null
@@ -1725,8 +1840,11 @@ function importSingleCodexSession(options: {
randomUUID(),
metadata,
{},
options.namespace
) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace)
options.namespace,
options.model ?? undefined,
undefined,
options.modelReasoningEffort ?? undefined
) ?? options.store.sessions.getOrCreateSession(randomUUID(), metadata, {}, options.namespace, options.model ?? undefined, undefined, options.modelReasoningEffort ?? undefined)
sessionId = createdSession.id
created = true
} else if (existingStored) {
@@ -1739,6 +1857,12 @@ function importSingleCodexSession(options: {
if (updatedMetadata.result !== 'success') {
throw new Error(`Failed to update metadata for Hapi session: ${existingStored.id}`)
}
if (options.model !== undefined) {
options.store.sessions.setSessionModel(existingStored.id, options.model, options.namespace, { touchUpdatedAt: false })
}
if (options.modelReasoningEffort !== undefined) {
options.store.sessions.setSessionModelReasoningEffort(existingStored.id, options.modelReasoningEffort, options.namespace, { touchUpdatedAt: false })
}
engine?.handleRealtimeEvent({ type: 'session-updated', sessionId: existingStored.id })
}
@@ -1748,6 +1872,10 @@ function importSingleCodexSession(options: {
const comparablePrefixCount = sessionId ? target.comparablePrefixCount : 0
const messagesToAppend = transcript.messages.slice(comparablePrefixCount)
const targetIsActive = Boolean(candidates.find((candidate) => candidate.sessionId === sessionId)?.active)
if (targetIsActive && messagesToAppend.length > 0) {
throw new Error('当前会话正在运行且 Codex transcript 有新消息,停止或归档后再同步,避免消息顺序错乱')
}
const appendedMessages = messagesToAppend.map((message) => options.store.messages.addMessage(sessionId!, message))
// 中文注释:更新 Hapi 会话的 updatedAt,并在已有会话追加时广播新增消息,让当前打开的聊天页立刻显示客户端新增内容。
@@ -1782,6 +1910,7 @@ function importSingleCodexSession(options: {
cwd: getDirectImportRouteContext().workspace,
output,
sessionIds: [options.codexSessionId],
hapiSessionIds: [sessionId],
syncedCount: 1
}
} catch (error) {
@@ -1798,13 +1927,18 @@ export async function importSelectedCodexSessions(options: {
store: Store
namespace: string
getSyncEngine?: () => SyncEngine | null
localSessions?: RemoteCodexSession[]
model?: string | null
modelReasoningEffort?: string | null
yolo?: boolean
machineId?: string | null
}): Promise<ScriptLaunchResponse> {
const codexSessionIds = options.codexSessionIds
if (codexSessionIds.length === 0) {
return createImportErrorResponse(codexSessionIds, NO_SYNC_SESSION_SELECTED_ERROR)
}
const localSessionsById = new Map(listLocalCodexSessions().map((session) => [session.id, session]))
const localSessionsById = new Map((options.localSessions ?? listLocalCodexSessions()).map((session) => [session.id, session]))
const results: ScriptLaunchResponse[] = []
for (const codexSessionId of codexSessionIds) {
const result = importSingleCodexSession({
@@ -1812,7 +1946,11 @@ export async function importSelectedCodexSessions(options: {
localSessionsById,
store: options.store,
namespace: options.namespace,
getSyncEngine: options.getSyncEngine
getSyncEngine: options.getSyncEngine,
model: options.model,
modelReasoningEffort: options.modelReasoningEffort,
yolo: options.yolo,
machineId: options.machineId
})
results.push(result)
@@ -1854,13 +1992,59 @@ export function createCodexDesktopRoutes(options: {
} satisfies CodexDesktopStatusResponse)
})
app.get('/codex/sessions', (c) => {
app.get('/codex/sessions', async (c) => {
const cwd = c.req.query('cwd')?.trim() || null
const machineId = c.req.query('machineId')?.trim() || null
const remote = await listCodexSessionsViaMachine({
engine: options.getSyncEngine(),
namespace: c.get('namespace'),
cwd,
machineId
})
if (remote.error) {
return c.json({
success: false,
error: remote.error,
sessions: [],
...(remote.machineId ? { machineId: remote.machineId } : {})
} satisfies CodexLocalSessionsResponse, 503)
}
return c.json({
success: true,
sessions: listLocalCodexSessions()
sessions: remote.sessions.map(({ messages: _messages, ...summary }) => summary),
...(remote.machineId ? { machineId: remote.machineId } : {})
} satisfies CodexLocalSessionsResponse)
})
app.post('/codex/archive-session', async (c) => {
const body = await c.req.json().catch(() => null)
const record = asRecord(body)
const sessionId = typeof record?.sessionId === 'string' ? record.sessionId.trim() : ''
const requestedMachineId = typeof record?.machineId === 'string' ? record.machineId.trim() : null
if (!sessionId) {
return c.json({ success: false, error: 'sessionId is required' }, 400)
}
const engine = options.getSyncEngine()
const machineId = resolveCodexImportMachineId(null, c.get('namespace'), engine, requestedMachineId)
if (!engine || !machineId) {
return c.json({ success: false, error: 'No online machine available for Codex history archive' }, 503)
}
const result = await engine.archiveCodexSessionForMachine(machineId, sessionId)
if (!result || typeof result !== 'object') {
return c.json({ success: false, error: 'Unexpected Codex archive RPC response', machineId }, 500)
}
if ((result as { success?: unknown }).success !== true) {
const error = typeof (result as { error?: unknown }).error === 'string'
? (result as { error: string }).error
: 'Failed to archive Codex session'
return c.json({ success: false, error, machineId }, 500)
}
return c.json({ success: true, archivedPath: (result as { archivedPath: string }).archivedPath, machineId })
})
app.post('/codex/sync-session', async (c) => {
const codexStatus = getCodexDesktopStatus()
const body = await c.req.json().catch(() => null)
@@ -1877,12 +2061,34 @@ export function createCodexDesktopRoutes(options: {
})
}
// 中文注释:这里直接读取本地 transcript 写入 Hapi store,不再启动隐藏 codex resume 进程,避免漏导入客户端新增内容
// 中文注释:hub 可能运行在服务器上;Codex transcript 必须通过用户本机 runner RPC 读取,不能扫描服务器磁盘
const remote = await listCodexSessionsViaMachine({
engine: options.getSyncEngine(),
namespace: c.get('namespace'),
cwd: parsed.cwd,
machineId: parsed.machineId,
sessionIds: parsed.sessionIds
})
if (remote.error) {
const { workspace } = getDirectImportRouteContext()
return c.json({
success: false,
error: remote.error,
cwd: workspace,
codexDesktopRunning: codexStatus.running,
codexClientAvailable: codexStatus.clientAvailable
})
}
const result = await importSelectedCodexSessions({
codexSessionIds: parsed.sessionIds,
store: options.store,
namespace: c.get('namespace'),
getSyncEngine: options.getSyncEngine
getSyncEngine: options.getSyncEngine,
localSessions: remote.sessions,
machineId: remote.machineId ?? null,
model: parsed.model,
modelReasoningEffort: parsed.modelReasoningEffort,
yolo: parsed.yolo
})
return c.json({
...result,