From abcf3a4bff8b685a70ceeb77008f4f217b43b67c Mon Sep 17 00:00:00 2001 From: EthanWang <58458517+dsus4wang@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:07:16 +0800 Subject: [PATCH 1/2] Add Codex session list/resume flow to web new-session UI --- cli/src/modules/common/codexSessions.test.ts | 59 +++++ cli/src/modules/common/codexSessions.ts | 227 ++++++++++++++++++ .../modules/common/handlers/codexSessions.ts | 26 ++ .../modules/common/registerCommonHandlers.ts | 2 + hub/src/sync/rpcGateway.ts | 24 ++ hub/src/sync/syncEngine.ts | 10 + hub/src/web/routes/machines.test.ts | 47 ++++ hub/src/web/routes/machines.ts | 46 +++- web/src/api/client.ts | 31 ++- .../NewSession/CodexSessionSelector.tsx | 69 ++++++ web/src/components/NewSession/index.tsx | 30 ++- web/src/hooks/mutations/useSpawnSession.ts | 4 +- web/src/hooks/queries/useCodexSessions.ts | 48 ++++ web/src/lib/locales/en.ts | 4 + web/src/lib/locales/zh-CN.ts | 4 + web/src/lib/query-keys.ts | 1 + web/src/types/api.ts | 17 ++ 17 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 cli/src/modules/common/codexSessions.test.ts create mode 100644 cli/src/modules/common/codexSessions.ts create mode 100644 cli/src/modules/common/handlers/codexSessions.ts create mode 100644 web/src/components/NewSession/CodexSessionSelector.tsx create mode 100644 web/src/hooks/queries/useCodexSessions.ts diff --git a/cli/src/modules/common/codexSessions.test.ts b/cli/src/modules/common/codexSessions.test.ts new file mode 100644 index 0000000000..44e6e2ec6f --- /dev/null +++ b/cli/src/modules/common/codexSessions.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { mkdir, writeFile, utimes } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { listCodexSessions } from './codexSessions' + +describe('listCodexSessions', () => { + const originalCodexHome = process.env.CODEX_HOME + let codexHome: string + + beforeEach(async () => { + codexHome = join(tmpdir(), `hapi-codex-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`) + process.env.CODEX_HOME = codexHome + await mkdir(join(codexHome, 'sessions'), { recursive: true }) + }) + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + return + } + process.env.CODEX_HOME = originalCodexHome + }) + + it('lists codex sessions and hides old entries by default', async () => { + const sessionsDir = join(codexHome, 'sessions') + const recentPath = join(sessionsDir, 'recent.jsonl') + const oldPath = join(sessionsDir, 'old.jsonl') + + await writeFile(recentPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } })}\n`) + await writeFile(oldPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-old', cwd: '/repo/old', model: 'gpt-5' } })}\n`) + + const oldDate = new Date(Date.now() - (190 * 24 * 60 * 60 * 1000)) + await utimes(oldPath, oldDate, oldDate) + + const recentOnly = await listCodexSessions() + expect(recentOnly.sessions.map((entry) => entry.id)).toEqual(['thread-recent']) + + const withOld = await listCodexSessions({ includeOld: true }) + expect(withOld.sessions.map((entry) => entry.id)).toEqual(['thread-recent', 'thread-old']) + expect(withOld.sessions[1]?.isOld).toBe(true) + }) + + it('supports cursor pagination', async () => { + const sessionsDir = join(codexHome, 'sessions') + for (let i = 0; i < 3; i++) { + const sessionPath = join(sessionsDir, `s-${i}.jsonl`) + await writeFile(sessionPath, `${JSON.stringify({ type: 'session_meta', payload: { id: `thread-${i}` } })}\n`) + } + + const page1 = await listCodexSessions({ includeOld: true, limit: 2 }) + expect(page1.sessions.length).toBe(2) + expect(page1.nextCursor).toBe('2') + + const page2 = await listCodexSessions({ includeOld: true, limit: 2, cursor: page1.nextCursor ?? undefined }) + expect(page2.sessions.length).toBe(1) + expect(page2.nextCursor).toBeNull() + }) +}) diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts new file mode 100644 index 0000000000..639ba08ece --- /dev/null +++ b/cli/src/modules/common/codexSessions.ts @@ -0,0 +1,227 @@ +import { homedir } from 'node:os'; +import { basename, extname, join } from 'node:path'; +import { promises as fs } from 'node:fs'; + +export interface CodexSessionSummary { + id: string; + title: string; + updatedAt: number; + path: string | null; + model: string | null; + isOld: boolean; +} + +export interface ListCodexSessionsRequest { + includeOld?: boolean; + olderThanDays?: number; + limit?: number; + cursor?: string; +} + +export interface ListCodexSessionsResponse { + success: boolean; + sessions?: CodexSessionSummary[]; + nextCursor?: string | null; + error?: string; +} + +type RawSession = { + id: string; + title: string; + updatedAt: number; + path: string | null; + model: string | null; +}; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as Record; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readCodexHome(): string { + return process.env.CODEX_HOME ?? join(homedir(), '.codex'); +} + +async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise { + const queue: string[] = [rootDir]; + const files: string[] = []; + + while (queue.length > 0 && files.length < maxFiles) { + const current = queue.shift(); + if (!current) { + break; + } + + let entries: fs.Dirent[]; + try { + entries = await fs.readdir(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + continue; + } + if (entry.isFile() && extname(entry.name) === '.jsonl') { + files.push(fullPath); + if (files.length >= maxFiles) { + break; + } + } + } + } + + return files; +} + +function parseSessionLine(line: string): { id?: string; title?: string; path?: string; model?: string } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + const record = asRecord(parsed); + if (!record) { + return null; + } + + const type = asNonEmptyString(record.type); + const payload = asRecord(record.payload); + + if (type === 'session_meta') { + const id = asNonEmptyString(payload?.id) ?? asNonEmptyString(record.id); + const path = asNonEmptyString(payload?.cwd) ?? asNonEmptyString(payload?.path); + const model = asNonEmptyString(payload?.model); + return { id: id ?? undefined, path: path ?? undefined, model: model ?? undefined }; + } + + if (type === 'event_msg') { + const messageType = asNonEmptyString(payload?.type); + if (messageType === 'agent_message') { + const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text); + if (text) { + return { title: text.slice(0, 80) }; + } + } + + if (messageType === 'thread_started') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + return id ? { id } : null; + } + } + + return null; +} + +async function parseSessionFile(filePath: string): Promise { + let stat: fs.Stats; + let content: string; + try { + [stat, content] = await Promise.all([ + fs.stat(filePath), + fs.readFile(filePath, 'utf8') + ]); + } catch { + return null; + } + + const lines = content.split('\n').filter((line) => line.trim().length > 0).slice(0, 400); + let id: string | null = null; + let title: string | null = null; + let path: string | null = null; + let model: string | null = null; + + for (const line of lines) { + const parsed = parseSessionLine(line); + if (!parsed) { + continue; + } + if (!id && parsed.id) { + id = parsed.id; + } + if (!title && parsed.title) { + title = parsed.title; + } + if (!path && parsed.path) { + path = parsed.path; + } + if (!model && parsed.model) { + model = parsed.model; + } + } + + const fallbackId = basename(filePath, extname(filePath)); + const resolvedId = id ?? fallbackId; + if (!resolvedId) { + return null; + } + + return { + id: resolvedId, + title: title ?? resolvedId, + updatedAt: Math.max(0, Math.floor(stat.mtimeMs)), + path, + model + }; +} + +export async function listCodexSessions(request: ListCodexSessionsRequest = {}): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> { + const includeOld = request.includeOld === true; + const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0 + ? Number(request.olderThanDays) + : 180; + const limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0 + ? Math.min(100, Math.floor(Number(request.limit))) + : 50; + const offset = Number.isFinite(Number(request.cursor)) && Number(request.cursor) >= 0 + ? Math.floor(Number(request.cursor)) + : 0; + + const sessionsDir = join(readCodexHome(), 'sessions'); + const files = await walkJsonlFiles(sessionsDir, 5000); + const raw = (await Promise.all(files.map((filePath) => parseSessionFile(filePath)))) + .filter((entry): entry is RawSession => entry !== null); + + const deduped = new Map(); + for (const entry of raw) { + const existing = deduped.get(entry.id); + if (!existing || existing.updatedAt < entry.updatedAt) { + deduped.set(entry.id, entry); + } + } + + const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; + const sorted = Array.from(deduped.values()) + .sort((a, b) => b.updatedAt - a.updatedAt) + .map((entry) => ({ + id: entry.id, + title: entry.title, + updatedAt: entry.updatedAt, + path: entry.path, + model: entry.model, + isOld: entry.updatedAt < cutoff + })); + + const filtered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld); + const sliced = filtered.slice(offset, offset + limit); + const nextOffset = offset + sliced.length; + + return { + sessions: sliced, + nextCursor: nextOffset < filtered.length ? String(nextOffset) : null + }; +} diff --git a/cli/src/modules/common/handlers/codexSessions.ts b/cli/src/modules/common/handlers/codexSessions.ts new file mode 100644 index 0000000000..e3e256fcc8 --- /dev/null +++ b/cli/src/modules/common/handlers/codexSessions.ts @@ -0,0 +1,26 @@ +import { logger } from '@/ui/logger'; +import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'; +import { + listCodexSessions, + type ListCodexSessionsRequest, + type ListCodexSessionsResponse +} from '../codexSessions'; +import { getErrorMessage, rpcError } from '../rpcResponses'; + +export function registerCodexSessionHandlers(rpcHandlerManager: RpcHandlerManager): void { + rpcHandlerManager.registerHandler('listCodexSessions', async (data) => { + logger.debug('List Codex sessions request'); + + try { + const result = await listCodexSessions(data ?? {}); + return { + success: true, + sessions: result.sessions, + nextCursor: result.nextCursor + }; + } catch (error) { + logger.debug('Failed to list Codex sessions:', error); + return rpcError(getErrorMessage(error, 'Failed to list Codex sessions')); + } + }); +} diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index fa8ba0b6df..68d9811953 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -1,6 +1,7 @@ import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager' import { registerBashHandlers } from './handlers/bash' import { registerCodexModelHandlers } from './handlers/codexModels' +import { registerCodexSessionHandlers } from './handlers/codexSessions' import { registerDirectoryHandlers } from './handlers/directories' import { registerDifftasticHandlers } from './handlers/difftastic' import { registerFileHandlers } from './handlers/files' @@ -13,6 +14,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerCodexSessionHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) registerDirectoryHandlers(rpcHandlerManager, workingDirectory) registerRipgrepHandlers(rpcHandlerManager, workingDirectory) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 7899261d9c..f996b42dae 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -58,6 +58,23 @@ export type RpcListCodexModelsResponse = { error?: string } + +export type RpcCodexSession = { + id: string + title: string + updatedAt: number + path: string | null + model: string | null + isOld: boolean +} + +export type RpcListCodexSessionsResponse = { + success: boolean + sessions?: RpcCodexSession[] + nextCursor?: string | null + error?: string +} + export class RpcGateway { constructor( private readonly io: Server, @@ -262,6 +279,13 @@ export class RpcGateway { return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse } + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.machineRpc(machineId, 'listCodexSessions', options ?? {}) as RpcListCodexSessionsResponse + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index d92037961f..466c4a889c 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -22,6 +22,7 @@ import { type RpcDeleteUploadResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListCodexSessionsResponse, type RpcPathExistsResponse, type RpcReadFileResponse, type RpcUploadFileResponse @@ -586,4 +587,13 @@ export class SyncEngine { async listCodexModelsForMachine(machineId: string): Promise { return await this.rpcGateway.listCodexModelsForMachine(machineId) } + + + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.rpcGateway.listCodexSessionsForMachine(machineId, options) + } + } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index f616adaa79..b88f968bb0 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -56,4 +56,51 @@ describe('machines routes', () => { ] }) }) + + it('returns Codex sessions for an online machine', async () => { + const machine = createMachine() + const engine = { + getMachine: () => machine, + getMachineByNamespace: () => machine, + listCodexSessionsForMachine: async () => ({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createMachinesRoutes(() => engine as SyncEngine)) + + const response = await app.request('/api/machines/machine-1/codex-sessions?includeOld=1&limit=20') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + sessions: [ + { + id: 'thread-1', + title: 'Fix auth bug', + updatedAt: 100, + path: '/repo', + model: 'gpt-5', + isOld: false + } + ], + nextCursor: null + }) + }) }) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index cf4a460553..d4d7051e5e 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -12,7 +12,8 @@ const spawnBodySchema = z.object({ modelReasoningEffort: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), - worktreeName: z.string().optional() + worktreeName: z.string().optional(), + resumeSessionId: z.string().optional() }) const pathsExistsSchema = z.object({ @@ -60,7 +61,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, - undefined, + parsed.data.resumeSessionId, parsed.data.effort ) return c.json(result) @@ -123,6 +124,47 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + + app.get('/machines/:id/codex-sessions', async (c) => { + const engine = getSyncEngine() + if (!engine) { + return c.json({ success: false, error: 'Not connected' }, 503) + } + + const machineId = c.req.param('id') + const machine = requireMachine(c, engine, machineId) + if (machine instanceof Response) { + return machine + } + + const query = c.req.query() + const includeOld = query.includeOld === '1' || query.includeOld === 'true' + const olderThanDays = Number.isFinite(Number(query.olderThanDays)) + ? Number(query.olderThanDays) + : undefined + const limit = Number.isFinite(Number(query.limit)) + ? Number(query.limit) + : undefined + const cursor = typeof query.cursor === 'string' && query.cursor.length > 0 + ? query.cursor + : undefined + + try { + const result = await engine.listCodexSessionsForMachine(machineId, { + includeOld, + olderThanDays, + limit, + cursor + }) + return c.json(result) + } catch (error) { + return c.json({ + success: false, + error: error instanceof Error ? error.message : 'Failed to list Codex sessions' + }, 500) + } + }) + app.get('/machines/:id/codex-models', async (c) => { const engine = getSyncEngine() if (!engine) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 19c577b54e..ef0bf4afbb 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -12,6 +12,7 @@ import type { MachinesResponse, MessagesResponse, CodexModelsResponse, + CodexSessionsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -414,14 +415,40 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + resumeSessionId?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId }) }) } + + async getMachineCodexSessions( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + const params = new URLSearchParams() + if (options?.includeOld !== undefined) { + params.set('includeOld', options.includeOld ? '1' : '0') + } + if (options?.olderThanDays !== undefined) { + params.set('olderThanDays', String(options.olderThanDays)) + } + if (options?.limit !== undefined) { + params.set('limit', String(options.limit)) + } + if (options?.cursor) { + params.set('cursor', options.cursor) + } + + const query = params.toString() + return await this.request( + `/api/machines/${encodeURIComponent(machineId)}/codex-sessions${query ? `?${query}` : ''}` + ) + } + async getMachineCodexModels(machineId: string): Promise { return await this.request( `/api/machines/${encodeURIComponent(machineId)}/codex-models` diff --git a/web/src/components/NewSession/CodexSessionSelector.tsx b/web/src/components/NewSession/CodexSessionSelector.tsx new file mode 100644 index 0000000000..3b1f428ac2 --- /dev/null +++ b/web/src/components/NewSession/CodexSessionSelector.tsx @@ -0,0 +1,69 @@ +import type { CodexSessionSummary } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' + +function formatDate(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) { + return '-' + } + return new Date(timestamp).toLocaleDateString() +} + +export function CodexSessionSelector(props: { + enabled: boolean + includeOld: boolean + sessions: CodexSessionSummary[] + selectedSessionId: string + isLoading: boolean + isDisabled?: boolean + error?: string | null + onToggleIncludeOld: (value: boolean) => void + onSelectSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + + if (!props.enabled) { + return null + } + + const options = [{ id: '', label: t('newSession.codexSession.newSession') }] + for (const session of props.sessions) { + const date = formatDate(session.updatedAt) + const location = session.path ? ` · ${session.path}` : '' + options.push({ + id: session.id, + label: `${session.title} (${date})${location}` + }) + } + + return ( +
+
+ + +
+ + {props.isLoading ?
{t('newSession.codexSession.loading')}
: null} + {props.error ?
{props.error}
: null} +
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 721596b8d5..df3d57f2b6 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform' import { useMachinePathsExists } from '@/hooks/useMachinePathsExists' import { useSpawnSession } from '@/hooks/mutations/useSpawnSession' import { useCodexModels } from '@/hooks/queries/useCodexModels' +import { useCodexSessions } from '@/hooks/queries/useCodexSessions' import { useSessions } from '@/hooks/queries/useSessions' import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions' import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions' @@ -17,6 +18,7 @@ import { DirectorySection } from './DirectorySection' import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' +import { CodexSessionSelector } from './CodexSessionSelector' import { ReasoningEffortSelector } from './ReasoningEffortSelector' import { loadPreferredAgent, @@ -53,6 +55,8 @@ export function NewSession(props: { const [model, setModel] = useState('auto') const [effort, setEffort] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') + const [showOldCodexSessions, setShowOldCodexSessions] = useState(false) + const [selectedCodexSessionId, setSelectedCodexSessionId] = useState('') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') @@ -69,6 +73,9 @@ export function NewSession(props: { useEffect(() => { setModel('auto') setEffort('auto') + if (agent !== 'codex') { + setSelectedCodexSessionId('') + } }, [agent]) useEffect(() => { @@ -106,6 +113,13 @@ export function NewSession(props: { machineId, enabled: agent === 'codex' && Boolean(machineId) }) + + const codexSessionsState = useCodexSessions({ + api: props.api, + machineId, + includeOld: showOldCodexSessions, + enabled: agent === 'codex' && Boolean(machineId) + }) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), [selectedMachine] @@ -191,6 +205,7 @@ export function NewSession(props: { const handleMachineChange = useCallback((newMachineId: string) => { setMachineId(newMachineId) + setSelectedCodexSessionId('') const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -291,7 +306,8 @@ export function NewSession(props: { modelReasoningEffort: resolvedModelReasoningEffort, yolo: yoloMode, sessionType, - worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined + worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined, + resumeSessionId: agent === 'codex' && selectedCodexSessionId ? selectedCodexSessionId : undefined }) if (result.type === 'success') { @@ -372,6 +388,18 @@ export function NewSession(props: { isDisabled={isFormDisabled} onEffortChange={setEffort} /> + + { diff --git a/web/src/hooks/queries/useCodexSessions.ts b/web/src/hooks/queries/useCodexSessions.ts new file mode 100644 index 0000000000..8be3f5a6f4 --- /dev/null +++ b/web/src/hooks/queries/useCodexSessions.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { CodexSessionSummary } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useCodexSessions(args: { + api: ApiClient | null + machineId?: string | null + includeOld?: boolean + enabled?: boolean +}): { + sessions: CodexSessionSummary[] + isLoading: boolean + error: string | null +} { + const { api, machineId } = args + const includeOld = args.includeOld === true + const enabled = Boolean(args.enabled && api && machineId) + + const query = useQuery({ + queryKey: queryKeys.machineCodexSessions(machineId ?? 'unknown', includeOld), + queryFn: async () => { + if (!api || !machineId) { + throw new Error('Codex sessions target unavailable') + } + return await api.getMachineCodexSessions(machineId, { + includeOld, + olderThanDays: 180, + limit: 200 + }) + }, + enabled, + staleTime: 30_000, + retry: false, + }) + + return { + sessions: query.data?.sessions ?? [], + isLoading: query.isLoading, + error: query.data?.success === false + ? (query.data.error ?? 'Failed to load Codex sessions') + : query.error instanceof Error + ? query.error.message + : query.error + ? 'Failed to load Codex sessions' + : null, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 1596adbd3f..1d234f585d 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -111,6 +111,10 @@ export default { 'newSession.effort': 'Effort', 'newSession.model.optional': 'optional', 'newSession.model.loadFailed': 'Failed to load Codex models', + 'newSession.codexSession.label': 'Resume Codex session', + 'newSession.codexSession.newSession': 'Start new Codex session', + 'newSession.codexSession.showOld': 'Show older than 6 months', + 'newSession.codexSession.loading': 'Loading Codex sessions…', 'newSession.reasoningEffort': 'Reasoning effort', 'newSession.yolo': 'YOLO mode', 'newSession.yolo.title': 'Bypass approvals and sandbox', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 8539a00d29..54d46a34bf 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -113,6 +113,10 @@ export default { 'newSession.effort': '思考强度', 'newSession.model.optional': '可选', 'newSession.model.loadFailed': '加载 Codex 模型失败', + 'newSession.codexSession.label': '恢复 Codex 会话', + 'newSession.codexSession.newSession': '新建 Codex 会话', + 'newSession.codexSession.showOld': '显示 6 个月前会话', + 'newSession.codexSession.loading': '正在加载 Codex 会话…', 'newSession.reasoningEffort': '推理强度', 'newSession.yolo': 'YOLO 模式', 'newSession.yolo.title': '跳过审批和沙箱', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index 2c723ff46d..b62fbdadf8 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -4,6 +4,7 @@ export const queryKeys = { messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const, + machineCodexSessions: (machineId: string, includeOld: boolean) => ['machine-codex-sessions', machineId, includeOld ? 'all' : 'recent'] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0dd55158df..df2a1c7519 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -223,6 +223,23 @@ export type CodexModelsResponse = { error?: string } + +export type CodexSessionSummary = { + id: string + title: string + updatedAt: number + path: string | null + model: string | null + isOld: boolean +} + +export type CodexSessionsResponse = { + success: boolean + sessions?: CodexSessionSummary[] + nextCursor?: string | null + error?: string +} + export type PushSubscriptionKeys = { p256dh: string auth: string From 1de72788e93c6aae6511a5af8bb05bda631093c0 Mon Sep 17 00:00:00 2001 From: Ethan Wang Date: Mon, 27 Apr 2026 11:39:10 +0000 Subject: [PATCH 2/2] restore Codex session history --- cli/src/codex/codexLocalLauncher.ts | 1 + cli/src/codex/importHistory.test.ts | 164 ++++++++++++ cli/src/codex/importHistory.ts | 143 ++++++++++ cli/src/codex/loop.ts | 4 +- cli/src/codex/runCodex.ts | 28 ++ cli/src/codex/session.ts | 4 + cli/src/codex/utils/codexEventConverter.ts | 9 +- .../codex/utils/codexSessionScanner.test.ts | 24 ++ cli/src/codex/utils/codexSessionScanner.ts | 8 + cli/src/codex/utils/codexUsage.test.ts | 136 ++++++++++ cli/src/codex/utils/codexUsage.ts | 192 ++++++++++++++ cli/src/commands/codex.test.ts | 10 + cli/src/commands/codex.ts | 3 + cli/src/modules/common/codexSessions.test.ts | 50 +++- cli/src/modules/common/codexSessions.ts | 247 +++++++++++++++++- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/buildCliArgs.test.ts | 10 + cli/src/runner/run.ts | 3 + hub/src/sync/rpcGateway.ts | 3 +- hub/src/sync/sessionModel.test.ts | 2 + hub/src/sync/syncEngine.ts | 5 +- hub/src/web/routes/machines.ts | 4 +- shared/src/schemas.ts | 38 ++- shared/src/types.ts | 3 + web/src/api/client.ts | 5 +- web/src/components/NewSession/index.tsx | 3 +- web/src/hooks/mutations/useSpawnSession.ts | 4 +- web/src/hooks/queries/useCodexModels.test.tsx | 46 ++++ web/src/hooks/queries/useCodexModels.ts | 8 + 29 files changed, 1132 insertions(+), 26 deletions(-) create mode 100644 cli/src/codex/importHistory.test.ts create mode 100644 cli/src/codex/importHistory.ts create mode 100644 cli/src/codex/utils/codexUsage.test.ts create mode 100644 cli/src/codex/utils/codexUsage.ts create mode 100644 web/src/hooks/queries/useCodexModels.test.tsx diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 2545f1038d..6ca07e4475 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -70,6 +70,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch } const createdScanner = await createCodexSessionScanner({ transcriptPath, + replayExistingEvents: session.importHistory, onSessionId: (sessionId) => { session.onSessionFound(sessionId); }, diff --git a/cli/src/codex/importHistory.test.ts b/cli/src/codex/importHistory.test.ts new file mode 100644 index 0000000000..33f398ee50 --- /dev/null +++ b/cli/src/codex/importHistory.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { importCodexSessionHistory } from './importHistory'; +import type { ApiSessionClient } from '@/lib'; +import type { Metadata } from '@hapi/protocol'; + +describe('importCodexSessionHistory', () => { + const originalCodexHome = process.env.CODEX_HOME; + let codexHome: string; + + beforeEach(async () => { + codexHome = join(tmpdir(), `hapi-codex-history-${Date.now()}-${Math.random().toString(16).slice(2)}`); + process.env.CODEX_HOME = codexHome; + await mkdir(join(codexHome, 'sessions', '2026', '04', '27'), { recursive: true }); + }); + + afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + await rm(codexHome, { recursive: true, force: true }); + }); + + it('imports user and agent messages from the matching Codex transcript', async () => { + const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl'); + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old answer' } }) + ].join('\n') + '\n' + ); + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: 'thread-1', thread_name: 'codex generated title', updated_at: '2026-04-27T00:00:00.000Z' })}\n` + ); + + const userMessages: string[] = []; + const agentMessages: unknown[] = []; + const updateMetadata = vi.fn(); + const session = { + updateMetadata, + sendUserMessage: (message: string) => userMessages.push(message), + sendAgentMessage: (message: unknown) => agentMessages.push(message), + } as unknown as ApiSessionClient; + + const result = await importCodexSessionHistory({ + session, + codexSessionId: 'thread-1', + }); + + expect(result).toEqual({ imported: 2, filePath: transcriptPath }); + expect(updateMetadata).toHaveBeenCalledTimes(2); + const metadata = updateMetadata.mock.calls.reduce( + (current, call) => call[0](current), + { path: '/repo', host: 'test' } + ); + expect(metadata).toMatchObject({ + codexSessionId: 'thread-1', + summary: { text: 'codex generated title' } + }); + expect(userMessages).toEqual(['old prompt']); + expect(agentMessages).toMatchObject([ + { type: 'message', message: 'old answer' } + ]); + }); + + it('restores Codex session metadata from transcript model, reasoning effort, and latest usage', async () => { + const transcriptPath = join(codexHome, 'sessions', '2026', '04', '27', 'session.jsonl'); + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-usage', model: 'gpt-5.4' } }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'turn_context', + model: 'gpt-5.4', + reasoning_effort: 'high' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + model_context_window: 100_000, + total_token_usage: { + input_tokens: 1000, + cached_input_tokens: 500, + output_tokens: 250, + reasoning_output_tokens: 250, + total_tokens: 2000 + } + }, + rate_limits: { + primary: { + used_percent: 25, + window_minutes: 300 + } + } + } + }) + ].join('\n') + '\n' + ); + + const updateMetadata = vi.fn(); + const applySessionConfig = vi.fn(); + const session = { + updateMetadata, + applySessionConfig, + sendUserMessage: vi.fn(), + sendAgentMessage: vi.fn(), + } as unknown as ApiSessionClient; + + const result = await importCodexSessionHistory({ + session, + codexSessionId: 'thread-usage', + }); + + expect(result).toMatchObject({ + imported: 1, + filePath: transcriptPath, + model: 'gpt-5.4', + modelReasoningEffort: 'high' + }); + expect(applySessionConfig).toHaveBeenCalledWith({ + model: 'gpt-5.4', + modelReasoningEffort: 'high' + }); + const metadata = updateMetadata.mock.calls.reduce( + (current, call) => call[0](current), + { path: '/repo', host: 'test' } + ); + expect(metadata).toMatchObject({ + codexSessionId: 'thread-usage', + codexUsage: { + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + }, + rateLimits: { + fiveHour: { + usedPercent: 25, + windowMinutes: 300 + } + }, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + }); + }); +}); diff --git a/cli/src/codex/importHistory.ts b/cli/src/codex/importHistory.ts new file mode 100644 index 0000000000..b606e2f746 --- /dev/null +++ b/cli/src/codex/importHistory.ts @@ -0,0 +1,143 @@ +import { readFile } from 'node:fs/promises'; +import type { ApiSessionClient } from '@/lib'; +import { findCodexSessionFile, findCodexSessionTitle, formatCodexSessionTitle } from '@/modules/common/codexSessions'; +import { logger } from '@/ui/logger'; +import { convertCodexEvent, type CodexSessionEvent } from './utils/codexEventConverter'; +import { normalizeCodexUsage } from './utils/codexUsage'; + +type TitleSource = 'index' | 'user' | 'agent'; +type ImportSessionConfig = { + model?: string; + modelReasoningEffort?: string; +}; +type ImportSessionClient = ApiSessionClient & { + applySessionConfig?: (config: ImportSessionConfig) => void; +}; + +function parseCodexSessionEvent(line: string): CodexSessionEvent | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') { + return null; + } + const record = parsed as Record; + if (typeof record.type !== 'string' || record.type.length === 0) { + return null; + } + return { + timestamp: typeof record.timestamp === 'string' ? record.timestamp : undefined, + type: record.type, + payload: record.payload + }; +} + +export async function importCodexSessionHistory(args: { + session: ImportSessionClient; + codexSessionId: string; +}): Promise<{ imported: number; filePath: string | null; model?: string; modelReasoningEffort?: string }> { + const filePath = await findCodexSessionFile(args.codexSessionId); + if (!filePath) { + logger.debug(`[codex-history-import] No transcript found for Codex session ${args.codexSessionId}`); + return { imported: 0, filePath: null }; + } + + const content = await readFile(filePath, 'utf8'); + let imported = 0; + let title = await findCodexSessionTitle(args.codexSessionId); + let titleSource: TitleSource | null = title ? 'index' : null; + let restoredModel: string | undefined; + let restoredModelReasoningEffort: string | undefined; + for (const line of content.split('\n')) { + if (!line.trim()) { + continue; + } + const event = parseCodexSessionEvent(line); + if (!event) { + continue; + } + const converted = convertCodexEvent(event); + if (converted?.sessionId) { + const payload = event.payload && typeof event.payload === 'object' + ? event.payload as Record + : null; + if (typeof payload?.model === 'string' && payload.model.length > 0) { + restoredModel = payload.model; + } + const sessionReasoningEffort = payload?.model_reasoning_effort ?? payload?.modelReasoningEffort ?? payload?.reasoning_effort ?? payload?.reasoningEffort; + if (typeof sessionReasoningEffort === 'string' && sessionReasoningEffort.length > 0) { + restoredModelReasoningEffort = sessionReasoningEffort; + } + args.session.updateMetadata((metadata) => ({ + ...metadata, + codexSessionId: converted.sessionId + })); + } + if (event.type === 'event_msg' && event.payload && typeof event.payload === 'object') { + const payload = event.payload as Record; + if (payload.type === 'turn_context') { + if (typeof payload.model === 'string' && payload.model.length > 0) { + restoredModel = payload.model; + } + const reasoningEffort = payload.reasoning_effort ?? payload.reasoningEffort ?? payload.model_reasoning_effort ?? payload.modelReasoningEffort; + if (typeof reasoningEffort === 'string' && reasoningEffort.length > 0) { + restoredModelReasoningEffort = reasoningEffort; + } + } + } + if (converted?.userMessage) { + const userTitle = formatCodexSessionTitle(converted.userMessage); + if (userTitle && titleSource !== 'index' && titleSource !== 'user') { + title = userTitle; + titleSource = 'user'; + } + args.session.sendUserMessage(converted.userMessage); + imported += 1; + } + if (converted?.message) { + if (converted.message.type === 'token_count') { + const codexUsage = normalizeCodexUsage(converted.message); + if (codexUsage) { + args.session.updateMetadata((metadata) => ({ + ...metadata, + codexUsage + })); + } + } + if (converted.message.type === 'message' && !title) { + title = formatCodexSessionTitle(converted.message.message); + titleSource = 'agent'; + } + args.session.sendAgentMessage(converted.message); + imported += 1; + } + } + + if (title) { + args.session.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + })); + } + + const restoredConfig: ImportSessionConfig = { + ...(restoredModel ? { model: restoredModel } : {}), + ...(restoredModelReasoningEffort ? { modelReasoningEffort: restoredModelReasoningEffort } : {}) + }; + if (restoredConfig.model || restoredConfig.modelReasoningEffort) { + args.session.applySessionConfig?.(restoredConfig); + } + + logger.debug(`[codex-history-import] Imported ${imported} messages from ${filePath}`); + return { + imported, + filePath, + ...restoredConfig + }; +} diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 223807b1c7..dbad40171a 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; + importHistory?: 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', + importHistory: opts.importHistory }); await runLocalRemoteSession({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index bb17a247d4..12dff6c10f 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -13,6 +13,7 @@ import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protoc import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { ReasoningEffort } from './appServerTypes'; +import { importCodexSessionHistory } from './importHistory'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -23,6 +24,7 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + importHistory?: boolean; model?: string; modelReasoningEffort?: ReasoningEffort; }): Promise { @@ -71,6 +73,31 @@ export async function runCodex(opts: { lifecycle.registerProcessHandlers(); registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + if (opts.importHistory && opts.resumeSessionId) { + try { + const importedHistory = await importCodexSessionHistory({ + session, + codexSessionId: opts.resumeSessionId + }); + if (!opts.model && importedHistory.model) { + currentModel = importedHistory.model; + } + if ( + !opts.modelReasoningEffort + && importedHistory.modelReasoningEffort + && REASONING_EFFORTS.has(importedHistory.modelReasoningEffort as ReasoningEffort) + ) { + currentModelReasoningEffort = importedHistory.modelReasoningEffort as ReasoningEffort; + } + } catch (error) { + logger.debug('[codex] Failed to import Codex session history:', error); + session.sendAgentMessage({ + type: 'message', + message: `Failed to import Codex session history: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + const applyCurrentConfigToSession = (options?: { syncModel?: boolean }) => { const sessionInstance = sessionWrapperRef.current; if (!sessionInstance) { @@ -231,6 +258,7 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + importHistory: opts.importHistory, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 524d21d7dd..dd80b3209c 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -38,6 +38,7 @@ export class CodexSession extends AgentSessionBase { model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; + importHistory?: boolean; }) { super({ api: opts.api, @@ -68,8 +69,11 @@ export class CodexSession extends AgentSessionBase { this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; this.collaborationMode = opts.collaborationMode; + this.importHistory = opts.importHistory === true; } + readonly importHistory: boolean; + onTranscriptPathFound(path: string): void { if (this.transcriptPath === path) { return; diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 24ecfd241f..a7d396652d 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -172,10 +172,17 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu } if (eventType === 'token_count') { - const info = asRecord(payloadRecord.info); + const rawInfo = asRecord(payloadRecord.info); + const info = rawInfo ? { ...rawInfo } : null; if (!info) { return null; } + if (info.rate_limits === undefined && info.rateLimits === undefined) { + const rateLimits = payloadRecord.rate_limits ?? payloadRecord.rateLimits; + if (rateLimits !== undefined) { + info.rate_limits = rateLimits; + } + } return { message: { type: 'token_count', diff --git a/cli/src/codex/utils/codexSessionScanner.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index 166fee7cd0..a005b611aa 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -59,6 +59,30 @@ describe('codexSessionScanner', () => { expect(events[0]?.type).toBe('event_msg'); }); + it('can replay existing events on startup', async () => { + await writeFile( + transcriptPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'session-123' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old answer' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + transcriptPath, + replayExistingEvents: true, + onEvent: (event) => events.push(event) + }); + + await wait(300); + expect(events.map((event) => event.payload)).toEqual([ + { id: 'session-123' }, + { type: 'user_message', message: 'old prompt' }, + { type: 'agent_message', message: 'old answer' } + ]); + }); + 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 2c09c5dc4c..7b235890dc 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -5,6 +5,7 @@ import type { CodexSessionEvent } from './codexEventConverter'; interface CodexSessionScannerOptions { transcriptPath: string | null; + replayExistingEvents?: boolean; onEvent: (event: CodexSessionEvent) => void; onSessionId?: (sessionId: string) => void; } @@ -32,6 +33,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private transcriptPath: string | null; private readonly onEvent: (event: CodexSessionEvent) => void; private readonly onSessionId?: (sessionId: string) => void; + private readonly replayExistingEvents: boolean; private readonly fileEpochByPath = new Map(); private readonly fileSizeByPath = new Map(); private observedSessionId: string | null = null; @@ -41,6 +43,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.transcriptPath = opts.transcriptPath; this.onEvent = opts.onEvent; this.onSessionId = opts.onSessionId; + this.replayExistingEvents = opts.replayExistingEvents === true; } async setTranscriptPath(transcriptPath: string): Promise { @@ -91,6 +94,11 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private async primeTranscript(filePath: string): Promise { const { events, nextCursor } = await this.readSessionFile(filePath, 0); + if (this.replayExistingEvents) { + for (const entry of events) { + this.onEvent(entry.event); + } + } const keys = events.map((entry) => this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex })); this.seedProcessedKeys(keys); this.setCursor(filePath, nextCursor); diff --git a/cli/src/codex/utils/codexUsage.test.ts b/cli/src/codex/utils/codexUsage.test.ts new file mode 100644 index 0000000000..1212d55d16 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeCodexUsage } from './codexUsage'; + +describe('normalizeCodexUsage', () => { + it('parses app-server token usage with context and rate-limit buckets', () => { + const usage = normalizeCodexUsage({ + model_context_window: 200_000, + used_tokens: 35_000, + total_token_usage: { + input_tokens: 10_000, + cached_input_tokens: 20_000, + output_tokens: 3_000, + reasoning_output_tokens: 2_000, + total_tokens: 35_000 + }, + last_token_usage: { + input_tokens: 100, + cached_input_tokens: 200, + output_tokens: 30, + reasoning_output_tokens: 20, + total_tokens: 350 + }, + rate_limits: { + primary: { + used_percent: 42.5, + window_minutes: 300, + resets_in_seconds: 600 + }, + secondary: { + used_percent: 9, + window_minutes: 10080, + reset_at: '2026-04-28T00:00:00.000Z' + } + } + }, { now: 1_000_000 }); + + expect(usage).toMatchObject({ + contextWindow: { + usedTokens: 35_000, + limitTokens: 200_000, + percent: 17.5, + updatedAt: 1_000_000 + }, + rateLimits: { + fiveHour: { + usedPercent: 42.5, + windowMinutes: 300, + resetAt: 1_600_000 + }, + weekly: { + usedPercent: 9, + windowMinutes: 10080, + resetAt: Date.parse('2026-04-28T00:00:00.000Z') + } + }, + totalTokenUsage: { + inputTokens: 10_000, + cachedInputTokens: 20_000, + outputTokens: 3_000, + reasoningOutputTokens: 2_000, + totalTokens: 35_000 + }, + lastTokenUsage: { + inputTokens: 100, + cachedInputTokens: 200, + outputTokens: 30, + reasoningOutputTokens: 20, + totalTokens: 350 + } + }); + }); + + it('parses transcript token_count info with sibling rate limits', () => { + const usage = normalizeCodexUsage({ + info: { + model_context_window: 100_000, + total_token_usage: { + input_tokens: 1000, + cached_input_tokens: 500, + output_tokens: 250, + reasoning_output_tokens: 250, + total_tokens: 2000 + } + }, + rate_limits: { + primary: { + used_percent: 80, + window_minutes: 300 + } + } + }, { now: 2_000_000 }); + + expect(usage?.contextWindow).toMatchObject({ + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + }); + expect(usage?.rateLimits.fiveHour).toMatchObject({ + usedPercent: 80, + windowMinutes: 300 + }); + }); + + it('uses last token usage for context window when cumulative total exceeds the model window', () => { + const usage = normalizeCodexUsage({ + info: { + model_context_window: 258_400, + total_token_usage: { + input_tokens: 2_767_000, + cached_input_tokens: 2_509_000, + output_tokens: 20_000, + reasoning_output_tokens: 3_000, + total_tokens: 2_787_000 + }, + last_token_usage: { + input_tokens: 75_918, + cached_input_tokens: 46_976, + output_tokens: 542, + reasoning_output_tokens: 52, + total_tokens: 76_460 + } + } + }, { now: 2_000_000 }); + + expect(usage?.contextWindow).toMatchObject({ + usedTokens: 76_460, + limitTokens: 258_400, + percent: (76_460 / 258_400) * 100 + }); + expect(usage?.totalTokenUsage?.totalTokens).toBe(2_787_000); + }); + + it('returns null when no supported usage fields are present', () => { + expect(normalizeCodexUsage({ message: 'hello' })).toBeNull(); + }); +}); diff --git a/cli/src/codex/utils/codexUsage.ts b/cli/src/codex/utils/codexUsage.ts new file mode 100644 index 0000000000..8b2f356de3 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.ts @@ -0,0 +1,192 @@ +import type { CodexTokenUsage, CodexUsage, CodexUsageRateLimit } from '@hapi/protocol/types'; + +type NormalizerOptions = { + now?: number; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function firstNumber(record: Record | null, keys: string[]): number | null { + if (!record) return null; + for (const key of keys) { + const value = asNumber(record[key]); + if (value !== null) return value; + } + return null; +} + +function normalizeTokenUsage(value: unknown): CodexTokenUsage | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const inputTokens = firstNumber(record, ['input_tokens', 'inputTokens']) ?? 0; + const cachedInputTokens = firstNumber(record, ['cached_input_tokens', 'cachedInputTokens', 'cache_read_input_tokens', 'cacheReadInputTokens']) ?? 0; + const outputTokens = firstNumber(record, ['output_tokens', 'outputTokens']) ?? 0; + const reasoningOutputTokens = firstNumber(record, ['reasoning_output_tokens', 'reasoningOutputTokens']) ?? 0; + const totalTokens = firstNumber(record, ['total_tokens', 'totalTokens']) + ?? inputTokens + cachedInputTokens + outputTokens + reasoningOutputTokens; + + if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && reasoningOutputTokens === 0 && totalTokens === 0) { + return undefined; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens + }; +} + +function parseResetAt(record: Record, now: number): number | undefined { + const direct = record.reset_at ?? record.resetAt; + if (typeof direct === 'string') { + const parsed = Date.parse(direct); + if (Number.isFinite(parsed)) return parsed; + } + const directNumber = asNumber(direct); + if (directNumber !== null) { + return directNumber < 10_000_000_000 ? directNumber * 1000 : directNumber; + } + + const resetsInSeconds = firstNumber(record, ['resets_in_seconds', 'resetsInSeconds', 'reset_in_seconds', 'resetInSeconds']); + if (resetsInSeconds !== null) { + return now + (resetsInSeconds * 1000); + } + + const resetsInMinutes = firstNumber(record, ['resets_in_minutes', 'resetsInMinutes', 'reset_in_minutes', 'resetInMinutes']); + if (resetsInMinutes !== null) { + return now + (resetsInMinutes * 60_000); + } + + return undefined; +} + +function normalizeRateLimit(value: unknown, now: number): CodexUsageRateLimit | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const usedPercent = firstNumber(record, ['used_percent', 'usedPercent', 'percent', 'usage_percent', 'usagePercent']); + const windowMinutes = firstNumber(record, ['window_minutes', 'windowMinutes', 'window', 'minutes']); + if (usedPercent === null || windowMinutes === null) { + return undefined; + } + + const resetAt = parseResetAt(record, now); + return { + usedPercent, + windowMinutes, + ...(resetAt !== undefined ? { resetAt } : {}) + }; +} + +function collectRateLimitCandidates(value: unknown): unknown[] { + const record = asRecord(value); + if (!record) return []; + + const direct = record.rate_limits ?? record.rateLimits; + const directRecord = asRecord(direct); + if (Array.isArray(direct)) return direct; + if (directRecord) { + return Object.values(directRecord); + } + + if (record.primary || record.secondary) { + return [record.primary, record.secondary]; + } + + return []; +} + +function unwrapUsagePayload(value: unknown): Record | null { + const record = asRecord(value); + if (!record) return null; + + const info = asRecord(record.info); + if (info) { + return { + ...record, + ...info, + rate_limits: info.rate_limits ?? info.rateLimits ?? record.rate_limits ?? record.rateLimits + }; + } + + const tokenUsage = asRecord(record.tokenUsage ?? record.token_usage); + if (tokenUsage) { + return { + ...record, + ...tokenUsage, + rate_limits: tokenUsage.rate_limits ?? tokenUsage.rateLimits ?? record.rate_limits ?? record.rateLimits + }; + } + + return record; +} + +export function normalizeCodexUsage(value: unknown, options: NormalizerOptions = {}): CodexUsage | null { + const now = options.now ?? Date.now(); + const record = unwrapUsagePayload(value); + if (!record) return null; + + const totalTokenUsage = normalizeTokenUsage(record.total_token_usage ?? record.totalTokenUsage ?? record.total_usage ?? record.totalUsage); + const lastTokenUsage = normalizeTokenUsage(record.last_token_usage ?? record.lastTokenUsage ?? record.last_usage ?? record.lastUsage); + const contextLimit = firstNumber(record, ['model_context_window', 'modelContextWindow', 'context_window', 'contextWindow']); + const explicitContextUsed = firstNumber(record, ['context_window_used_tokens', 'contextWindowUsedTokens', 'used_tokens', 'usedTokens']); + const cumulativeTotal = totalTokenUsage?.totalTokens + ?? firstNumber(asRecord(record.total_token_usage ?? record.totalTokenUsage), ['total_tokens', 'totalTokens']); + const cumulativeFitsContext = cumulativeTotal !== undefined + && cumulativeTotal !== null + && contextLimit !== null + && cumulativeTotal <= contextLimit + ? cumulativeTotal + : null; + const contextUsed = explicitContextUsed + ?? lastTokenUsage?.totalTokens + ?? firstNumber(asRecord(record.last_token_usage ?? record.lastTokenUsage), ['total_tokens', 'totalTokens']) + ?? cumulativeFitsContext; + + const rateLimits: CodexUsage['rateLimits'] = {}; + for (const candidate of collectRateLimitCandidates(record)) { + const bucket = normalizeRateLimit(candidate, now); + if (!bucket) continue; + if (bucket.windowMinutes === 300) { + rateLimits.fiveHour = bucket; + } else if (bucket.windowMinutes === 10080) { + rateLimits.weekly = bucket; + } + } + + const contextWindow = contextLimit !== null && contextLimit > 0 && contextUsed !== null + ? { + usedTokens: contextUsed, + limitTokens: contextLimit, + percent: Math.min(100, Math.max(0, (contextUsed / contextLimit) * 100)), + updatedAt: now + } + : undefined; + + if (!contextWindow && !totalTokenUsage && !lastTokenUsage && !rateLimits.fiveHour && !rateLimits.weekly) { + return null; + } + + return { + ...(contextWindow ? { contextWindow } : {}), + rateLimits, + ...(totalTokenUsage ? { totalTokenUsage } : {}), + ...(lastTokenUsage ? { lastTokenUsage } : {}) + }; +} diff --git a/cli/src/commands/codex.test.ts b/cli/src/commands/codex.test.ts index a11822877d..8072eeb1e1 100644 --- a/cli/src/commands/codex.test.ts +++ b/cli/src/commands/codex.test.ts @@ -80,6 +80,16 @@ describe('codexCommand', () => { }) }) + it('parses the internal history import flag', async () => { + await codexCommand.run(createCommandContext(['resume', 'session-123', '--hapi-import-history', '--started-by', 'runner'])) + + expect(runCodexMock).toHaveBeenCalledWith({ + resumeSessionId: 'session-123', + importHistory: true, + startedBy: 'runner' + }) + }) + it('prints the upgrade error and exits when the local version check fails', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 8db32de440..25cc287c5d 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -34,6 +34,7 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + importHistory?: boolean model?: string modelReasoningEffort?: ReasoningEffort } = {} @@ -76,6 +77,8 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --model-reasoning-effort value') } options.modelReasoningEffort = parseReasoningEffort(effort) + } else if (arg === '--hapi-import-history') { + options.importHistory = true } else { unknownArgs.push(arg) } diff --git a/cli/src/modules/common/codexSessions.test.ts b/cli/src/modules/common/codexSessions.test.ts index 44e6e2ec6f..f6579cc060 100644 --- a/cli/src/modules/common/codexSessions.test.ts +++ b/cli/src/modules/common/codexSessions.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdir, writeFile, utimes } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { listCodexSessions } from './codexSessions' +import { findCodexSessionTitle, listCodexSessions } from './codexSessions' describe('listCodexSessions', () => { const originalCodexHome = process.env.CODEX_HOME @@ -27,7 +27,18 @@ describe('listCodexSessions', () => { const recentPath = join(sessionsDir, 'recent.jsonl') const oldPath = join(sessionsDir, 'old.jsonl') - await writeFile(recentPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } })}\n`) + await writeFile( + recentPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'agent reply should not win' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'user session title' } }) + ].join('\n') + '\n' + ) + await writeFile( + join(codexHome, 'session_index.jsonl'), + `${JSON.stringify({ id: 'thread-recent', thread_name: 'codex generated title', updated_at: '2026-04-27T00:00:00.000Z' })}\n` + ) await writeFile(oldPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-old', cwd: '/repo/old', model: 'gpt-5' } })}\n`) const oldDate = new Date(Date.now() - (190 * 24 * 60 * 60 * 1000)) @@ -35,6 +46,7 @@ describe('listCodexSessions', () => { const recentOnly = await listCodexSessions() expect(recentOnly.sessions.map((entry) => entry.id)).toEqual(['thread-recent']) + expect(recentOnly.sessions[0]?.title).toBe('codex generated title') const withOld = await listCodexSessions({ includeOld: true }) expect(withOld.sessions.map((entry) => entry.id)).toEqual(['thread-recent', 'thread-old']) @@ -56,4 +68,36 @@ describe('listCodexSessions', () => { expect(page2.sessions.length).toBe(1) expect(page2.nextCursor).toBeNull() }) + + it('uses transcript thread names before first user message', async () => { + const sessionPath = join(codexHome, 'sessions', 'named.jsonl') + await writeFile( + sessionPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-named', cwd: '/repo/named', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'long first user message' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'thread_name_updated', thread_id: 'thread-named', thread_name: 'short generated title' } }) + ].join('\n') + '\n' + ) + + const result = await listCodexSessions({ includeOld: true }) + expect(result.sessions[0]?.title).toBe('short generated title') + await expect(findCodexSessionTitle('thread-named')).resolves.toBe('short generated title') + }) + + it('falls back to the first user message when no generated title exists', async () => { + const sessionPath = join(codexHome, 'sessions', 'untitled.jsonl') + await writeFile( + sessionPath, + [ + JSON.stringify({ type: 'session_meta', payload: { id: 'thread-untitled', cwd: '/repo/untitled', model: 'gpt-5' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'agent reply should not win' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'first user message fallback' } }) + ].join('\n') + '\n' + ) + + const result = await listCodexSessions({ includeOld: true }) + expect(result.sessions[0]?.title).toBe('first user message fallback') + await expect(findCodexSessionTitle('thread-untitled')).resolves.toBe('first user message fallback') + }) }) diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts index 639ba08ece..f20d997e57 100644 --- a/cli/src/modules/common/codexSessions.ts +++ b/cli/src/modules/common/codexSessions.ts @@ -1,6 +1,7 @@ import { homedir } from 'node:os'; import { basename, extname, join } from 'node:path'; import { promises as fs } from 'node:fs'; +import type { Dirent, Stats } from 'node:fs'; export interface CodexSessionSummary { id: string; @@ -28,11 +29,29 @@ export interface ListCodexSessionsResponse { type RawSession = { id: string; title: string; + titleSource: TitleSource; updatedAt: number; path: string | null; model: string | null; }; +type IndexedSessionTitle = { + title: string; + updatedAt: number; +}; + +type SqliteDatabaseConstructor = new (path: string, options?: { readonly?: boolean }) => { + query: (sql: string) => { all: () => unknown[] }; + close: (throwOnError?: boolean) => void; +}; + +type TitleSource = 'generated' | 'user' | 'agent' | 'fallback'; + +export function formatCodexSessionTitle(text: string): string | null { + const title = text.replace(/\s+/g, ' ').trim(); + return title.length > 0 ? title.slice(0, 80) : null; +} + function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object') { return null; @@ -48,6 +67,17 @@ function readCodexHome(): string { return process.env.CODEX_HOME ?? join(homedir(), '.codex'); } +function parseIndexUpdatedAt(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; + } + return 0; +} + async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise { const queue: string[] = [rootDir]; const files: string[] = []; @@ -58,7 +88,7 @@ async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + return content.split('\n').filter((line) => line.trim().length > 0); + } catch { + return null; + } +} + +function titleSourcePriority(source: TitleSource | undefined): number { + switch (source) { + case 'generated': + return 3; + case 'user': + return 2; + case 'agent': + return 1; + default: + return 0; + } +} + +function parseSessionLine(line: string): { id?: string; title?: string; titleSource?: TitleSource; path?: string; model?: string } | null { let parsed: unknown; try { parsed = JSON.parse(line); @@ -111,10 +163,28 @@ function parseSessionLine(line: string): { id?: string; title?: string; path?: s if (type === 'event_msg') { const messageType = asNonEmptyString(payload?.type); + if (messageType === 'thread_name_updated') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + const text = asNonEmptyString(payload?.thread_name) ?? asNonEmptyString(payload?.threadName) ?? asNonEmptyString(payload?.title); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { id: id ?? undefined, title, titleSource: 'generated' }; + } + } + + if (messageType === 'user_message') { + const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text) ?? asNonEmptyString(payload?.content); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { title, titleSource: 'user' }; + } + } + if (messageType === 'agent_message') { const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text); - if (text) { - return { title: text.slice(0, 80) }; + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { title, titleSource: 'agent' }; } } @@ -127,25 +197,121 @@ function parseSessionLine(line: string): { id?: string; title?: string; path?: s return null; } +function parseSessionIndexLine(line: string): { id: string; title: string; updatedAt: number } | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + + const record = asRecord(parsed); + if (!record) { + return null; + } + + const id = asNonEmptyString(record.id) ?? asNonEmptyString(record.session_id) ?? asNonEmptyString(record.sessionId); + const title = asNonEmptyString(record.thread_name) ?? asNonEmptyString(record.title) ?? asNonEmptyString(record.name); + if (!id || !title) { + return null; + } + + return { + id, + title: formatCodexSessionTitle(title) ?? title, + updatedAt: parseIndexUpdatedAt(record.updated_at ?? record.updatedAt ?? record.ts) + }; +} + +async function readCodexSessionIndex(): Promise> { + const lines = await readJsonlLines(join(readCodexHome(), 'session_index.jsonl')); + const titles = new Map(); + if (!lines) { + return titles; + } + + for (const line of lines) { + const parsed = parseSessionIndexLine(line); + if (!parsed) { + continue; + } + const existing = titles.get(parsed.id); + if (!existing || existing.updatedAt <= parsed.updatedAt) { + titles.set(parsed.id, { + title: parsed.title, + updatedAt: parsed.updatedAt + }); + } + } + return titles; +} + +async function loadBunSqliteDatabase(): Promise { + try { + const dynamicImport = new Function('specifier', 'return import(specifier)') as (specifier: string) => Promise<{ Database: SqliteDatabaseConstructor }>; + return (await dynamicImport('bun:sqlite')).Database; + } catch { + return null; + } +} + +async function readCodexThreadTitles(): Promise> { + const titles = new Map(); + const Database = await loadBunSqliteDatabase(); + if (!Database) { + return titles; + } + + let db: InstanceType | null = null; + try { + db = new Database(join(readCodexHome(), 'state_5.sqlite'), { readonly: true }); + const rows = db.query(` + SELECT id, title, updated_at, updated_at_ms + FROM threads + WHERE title IS NOT NULL AND title != '' + `).all() as Array<{ id: unknown; title: unknown; updated_at: unknown; updated_at_ms: unknown }>; + + for (const row of rows) { + const id = asNonEmptyString(row.id); + const title = asNonEmptyString(row.title); + if (!id || !title) { + continue; + } + titles.set(id, { + title: formatCodexSessionTitle(title) ?? title, + updatedAt: parseIndexUpdatedAt(row.updated_at_ms ?? row.updated_at) + }); + } + } catch { + return titles; + } finally { + db?.close(false); + } + return titles; +} + async function parseSessionFile(filePath: string): Promise { - let stat: fs.Stats; - let content: string; + let stat: Stats; + let lines: string[] | null; try { - [stat, content] = await Promise.all([ + [stat, lines] = await Promise.all([ fs.stat(filePath), - fs.readFile(filePath, 'utf8') + readJsonlLines(filePath) ]); } catch { return null; } + if (!lines) { + return null; + } - const lines = content.split('\n').filter((line) => line.trim().length > 0).slice(0, 400); let id: string | null = null; let title: string | null = null; + let titleSource: TitleSource = 'fallback'; let path: string | null = null; let model: string | null = null; - for (const line of lines) { + for (const line of lines.slice(0, 400)) { const parsed = parseSessionLine(line); if (!parsed) { continue; @@ -153,8 +319,9 @@ async function parseSessionFile(filePath: string): Promise { if (!id && parsed.id) { id = parsed.id; } - if (!title && parsed.title) { + if (parsed.title && (!title || titleSourcePriority(parsed.titleSource) > titleSourcePriority(titleSource))) { title = parsed.title; + titleSource = parsed.titleSource ?? titleSource; } if (!path && parsed.path) { path = parsed.path; @@ -173,12 +340,59 @@ async function parseSessionFile(filePath: string): Promise { return { id: resolvedId, title: title ?? resolvedId, + titleSource: title ? titleSource : 'fallback', updatedAt: Math.max(0, Math.floor(stat.mtimeMs)), path, model }; } +export async function findCodexSessionFile(sessionId: string): Promise { + if (!sessionId.trim()) { + return null; + } + + const sessionsDir = join(readCodexHome(), 'sessions'); + const files = await walkJsonlFiles(sessionsDir, 5000); + for (const filePath of files) { + const lines = await readJsonlLines(filePath); + if (!lines) { + continue; + } + for (const line of lines.slice(0, 400)) { + const parsed = parseSessionLine(line); + if (parsed?.id === sessionId) { + return filePath; + } + } + } + return null; +} + +export async function findCodexSessionTitle(sessionId: string): Promise { + if (!sessionId.trim()) { + return null; + } + + const indexedTitle = (await readCodexSessionIndex()).get(sessionId)?.title; + if (indexedTitle) { + return indexedTitle; + } + + const sessionFile = await findCodexSessionFile(sessionId); + const parsedSession = sessionFile ? await parseSessionFile(sessionFile) : null; + if (parsedSession?.titleSource === 'generated') { + return parsedSession.title; + } + + const threadTitle = (await readCodexThreadTitles()).get(sessionId)?.title; + if (threadTitle) { + return threadTitle; + } + + return parsedSession?.title ?? null; +} + export async function listCodexSessions(request: ListCodexSessionsRequest = {}): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> { const includeOld = request.includeOld === true; const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0 @@ -192,7 +406,11 @@ export async function listCodexSessions(request: ListCodexSessionsRequest = {}): : 0; const sessionsDir = join(readCodexHome(), 'sessions'); - const files = await walkJsonlFiles(sessionsDir, 5000); + const [files, indexedTitles, threadTitles] = await Promise.all([ + walkJsonlFiles(sessionsDir, 5000), + readCodexSessionIndex(), + readCodexThreadTitles() + ]); const raw = (await Promise.all(files.map((filePath) => parseSessionFile(filePath)))) .filter((entry): entry is RawSession => entry !== null); @@ -209,7 +427,10 @@ export async function listCodexSessions(request: ListCodexSessionsRequest = {}): .sort((a, b) => b.updatedAt - a.updatedAt) .map((entry) => ({ id: entry.id, - title: entry.title, + title: indexedTitles.get(entry.id)?.title + ?? (entry.titleSource === 'generated' ? entry.title : undefined) + ?? threadTitles.get(entry.id)?.title + ?? entry.title, updatedAt: entry.updatedAt, path: entry.path, model: entry.model, diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 6336f57dd8..0e7d9cc13c 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -3,6 +3,7 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + importHistory?: boolean approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' model?: string diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 612e2d1d6f..cd6073b04b 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -61,4 +61,14 @@ describe('buildCliArgs', () => { expect(args).toContain(mode) } }) + + it('adds Codex history import flag only for Codex resume', () => { + const args = buildCliArgs('codex', { + directory: '/tmp', + resumeSessionId: 'thread-1', + importHistory: true, + }) + + expect(args).toEqual(expect.arrayContaining(['codex', 'resume', 'thread-1', '--hapi-import-history'])) + }) }) diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 2e63611011..2778e71607 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -918,6 +918,9 @@ export function buildCliArgs( if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); + if (options.importHistory) { + args.push('--hapi-import-history'); + } } else if (agent === 'cursor') { args.push('--resume', options.resumeSessionId); } else { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index f996b42dae..e20bb55533 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -147,6 +147,7 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + importHistory?: boolean, effort?: string, permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { @@ -154,7 +155,7 @@ export class RpcGateway { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, importHistory, effort, permissionMode } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index f743fe656a..d7cbc22a01 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -473,6 +473,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, effort?: string ) => { capturedModel = model @@ -654,6 +655,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, _effort?: string, permissionMode?: string ) => { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 466c4a889c..c2d1b0d596 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -290,8 +290,8 @@ export class SyncEngine { sentFrom?: 'telegram-bot' | 'webapp' } ): Promise { - await this.messageService.sendMessage(sessionId, payload) this.sessionCache.markMessageQueued(sessionId) + await this.messageService.sendMessage(sessionId, payload) } async approvePermission( @@ -375,6 +375,7 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + importHistory?: boolean, effort?: string, permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { @@ -388,6 +389,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + importHistory, effort, permissionMode ) @@ -461,6 +463,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + false, session.effort ?? undefined, session.permissionMode ?? undefined ) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index d4d7051e5e..cdfa1af90f 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -13,7 +13,8 @@ const spawnBodySchema = z.object({ yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional(), - resumeSessionId: z.string().optional() + resumeSessionId: z.string().optional(), + importHistory: z.boolean().optional() }) const pathsExistsSchema = z.object({ @@ -62,6 +63,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.sessionType, parsed.data.worktreeName, parsed.data.resumeSessionId, + parsed.data.importHistory, parsed.data.effort ) return c.json(result) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index cb9f448ddf..3d96094ff6 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -19,6 +19,41 @@ export const WorktreeMetadataSchema = z.object({ export type WorktreeMetadata = z.infer +export const CodexTokenUsageSchema = z.object({ + inputTokens: z.number(), + cachedInputTokens: z.number(), + outputTokens: z.number(), + reasoningOutputTokens: z.number(), + totalTokens: z.number() +}) + +export type CodexTokenUsage = z.infer + +export const CodexUsageRateLimitSchema = z.object({ + usedPercent: z.number(), + windowMinutes: z.number(), + resetAt: z.number().optional() +}) + +export type CodexUsageRateLimit = z.infer + +export const CodexUsageSchema = z.object({ + contextWindow: z.object({ + usedTokens: z.number(), + limitTokens: z.number(), + percent: z.number(), + updatedAt: z.number() + }).optional(), + rateLimits: z.object({ + fiveHour: CodexUsageRateLimitSchema.optional(), + weekly: CodexUsageRateLimitSchema.optional() + }).optional().default({}), + totalTokenUsage: CodexTokenUsageSchema.optional(), + lastTokenUsage: CodexTokenUsageSchema.optional() +}) + +export type CodexUsage = z.infer + export const MetadataSchema = z.object({ path: z.string(), host: z.string(), @@ -46,7 +81,8 @@ export const MetadataSchema = z.object({ archivedBy: z.string().optional(), archiveReason: z.string().optional(), flavor: z.string().nullish(), - worktree: WorktreeMetadataSchema.optional() + worktree: WorktreeMetadataSchema.optional(), + codexUsage: CodexUsageSchema.optional() }) export type Metadata = z.infer diff --git a/shared/src/types.ts b/shared/src/types.ts index 69caa1c912..1b2391c51a 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -3,6 +3,9 @@ export type { AgentStateCompletedRequest, AgentStateRequest, AttachmentMetadata, + CodexTokenUsage, + CodexUsage, + CodexUsageRateLimit, DecryptedMessage, Metadata, Session, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ef0bf4afbb..4dd07efa4d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -416,11 +416,12 @@ export class ApiClient { sessionType?: 'simple' | 'worktree', worktreeName?: string, effort?: string, - resumeSessionId?: string + resumeSessionId?: string, + importHistory?: boolean ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId, importHistory }) }) } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index df3d57f2b6..703f78fc0c 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -307,7 +307,8 @@ export function NewSession(props: { yolo: yoloMode, sessionType, worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined, - resumeSessionId: agent === 'codex' && selectedCodexSessionId ? selectedCodexSessionId : undefined + resumeSessionId: agent === 'codex' && selectedCodexSessionId ? selectedCodexSessionId : undefined, + importHistory: agent === 'codex' && Boolean(selectedCodexSessionId) }) if (result.type === 'success') { diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index 5503a6ebd7..54c7979ac3 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -14,6 +14,7 @@ type SpawnInput = { sessionType?: 'simple' | 'worktree' worktreeName?: string resumeSessionId?: string + importHistory?: boolean } export function useSpawnSession(api: ApiClient | null): { @@ -38,7 +39,8 @@ export function useSpawnSession(api: ApiClient | null): { input.sessionType, input.worktreeName, input.effort, - input.resumeSessionId + input.resumeSessionId, + input.importHistory ) }, onSuccess: () => { diff --git a/web/src/hooks/queries/useCodexModels.test.tsx b/web/src/hooks/queries/useCodexModels.test.tsx new file mode 100644 index 0000000000..e9a5882cfe --- /dev/null +++ b/web/src/hooks/queries/useCodexModels.test.tsx @@ -0,0 +1,46 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { describe, expect, it } from 'vitest' +import type { ApiClient } from '@/api/client' +import { useCodexModels } from './useCodexModels' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +describe('useCodexModels', () => { + it('hides cached errors when disabled', async () => { + const api = { + getSessionCodexModels: async () => { + throw new Error('HTTP 409 Conflict: {"error":"Session is inactive"}') + }, + } as unknown as ApiClient + + const { result, rerender } = renderHook( + ({ enabled }) => useCodexModels({ + api, + sessionId: 'session-1', + enabled, + }), + { + wrapper: createWrapper(), + initialProps: { enabled: true }, + } + ) + + await waitFor(() => { + expect(result.current.error).toContain('Session is inactive') + }) + + rerender({ enabled: false }) + + expect(result.current.error).toBeNull() + expect(result.current.isLoading).toBe(false) + }) +}) diff --git a/web/src/hooks/queries/useCodexModels.ts b/web/src/hooks/queries/useCodexModels.ts index 016471a4e5..ec2fb2d6c0 100644 --- a/web/src/hooks/queries/useCodexModels.ts +++ b/web/src/hooks/queries/useCodexModels.ts @@ -38,6 +38,14 @@ export function useCodexModels(args: { retry: false, }) + if (!enabled) { + return { + models: [], + isLoading: false, + error: null, + } + } + return { models: query.data?.models ?? [], isLoading: query.isLoading,