From 8f5f164eb1a1497331e4907da197acc11d54eeae 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 01/19] Add Codex session list/resume flow to web new-session UI --- bun.lock | 2 + 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 | 9 + hub/src/web/routes/machines.test.ts | 47 ++++ hub/src/web/routes/machines.ts | 43 +++- shared/src/apiTypes.ts | 3 +- shared/src/rpcMethods.ts | 1 + web/src/api/client.ts | 31 ++- .../NewSession/CodexSessionSelector.tsx | 69 ++++++ web/src/components/NewSession/index.tsx | 31 ++- 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 | 16 ++ 20 files changed, 645 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/bun.lock b/bun.lock index 94c5fb529c..0bcd2d047f 100644 --- a/bun.lock +++ b/bun.lock @@ -1074,6 +1074,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.20.1", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-VWPCKdAgwfUNBRI9Xy14CKjx1d7JS1irOja5l6zufpaTi139jc51gyDcWFfygMwttQlNimmh2qHTfaFqqvcdNg=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.20.1", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kHsA3aV9LlIbI0kpqeF8oFeSCLCubaGVHnC3l5AH51KbvlDGeYzXyr1S8KEdgVgd1Gg3cS6LmwYL/xnyr6WO5Q=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], 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..4ba87474a4 --- /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, type Dirent, type Stats } 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: 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: 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 b555593af6..575edfaae0 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 { registerCursorModelHandlers } from './handlers/cursorModels' import { registerOpencodeModelHandlers } from './handlers/opencodeModels' import { registerDirectoryHandlers } from './handlers/directories' @@ -15,6 +16,7 @@ import { registerUploadHandlers } from './handlers/uploads' export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) + registerCodexSessionHandlers(rpcHandlerManager) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 0f37188721..a11f4936cc 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -38,6 +38,23 @@ export type RpcListCursorModelsResponse = CursorModelsResponse export type RpcOpencodeModel = OpencodeModelSummary export type RpcListOpencodeModelsResponse = OpencodeModelsResponse + +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, @@ -246,6 +263,13 @@ export class RpcGateway { return await this.sessionRpc(sessionId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse } + async listCodexSessionsForMachine( + machineId: string, + options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string } + ): Promise { + return await this.machineRpc(machineId, RPC_METHODS.ListCodexSessions, options ?? {}) as RpcListCodexSessionsResponse + } + async listCursorModelsForMachine(machineId: string): Promise { return await this.machineRpc(machineId, RPC_METHODS.ListCursorModels, {}, MODEL_LIST_RPC_TIMEOUT_MS) as RpcListCursorModelsResponse } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index a61578211e..99111ad9ad 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -27,6 +27,7 @@ import { type RpcGeneratedImageResponse, type RpcListDirectoryResponse, type RpcListCodexModelsResponse, + type RpcListCodexSessionsResponse, type RpcListCursorModelsResponse, type RpcListOpencodeModelsResponse, type RpcCursorModel, @@ -47,6 +48,7 @@ export type { RpcGeneratedImageResponse, RpcListDirectoryResponse, RpcListCodexModelsResponse, + RpcListCodexSessionsResponse, RpcListCursorModelsResponse, RpcListOpencodeModelsResponse, RpcCursorModel, @@ -1073,6 +1075,13 @@ export class SyncEngine { 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) + } + async listCursorModelsForSession(sessionId: string): Promise { return await this.rpcGateway.listCursorModelsForSession(sessionId) } diff --git a/hub/src/web/routes/machines.test.ts b/hub/src/web/routes/machines.test.ts index 3c4e6457ba..c9b33e5c95 100644 --- a/hub/src/web/routes/machines.test.ts +++ b/hub/src/web/routes/machines.test.ts @@ -57,6 +57,53 @@ 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 + }) + }) + it('returns 400 when /opencode-models is called without cwd', async () => { const machine = createMachine() const engine = { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 7288a42de0..d9c349a831 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -49,7 +49,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) @@ -112,6 +112,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/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e9ea6a1fff..d3160420d9 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -198,7 +198,8 @@ export const SpawnSessionRequestSchema = 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() }) export type SpawnSessionRequest = z.infer diff --git a/shared/src/rpcMethods.ts b/shared/src/rpcMethods.ts index 2d77c1a195..e2f1127f57 100644 --- a/shared/src/rpcMethods.ts +++ b/shared/src/rpcMethods.ts @@ -26,6 +26,7 @@ export const RPC_METHODS = { ListSlashCommands: 'listSlashCommands', ListSkills: 'listSkills', ListCodexModels: 'listCodexModels', + ListCodexSessions: 'listCodexSessions', ListCursorModels: 'listCursorModels', ListOpencodeModels: 'listOpencodeModels', ListOpencodeModelsForCwd: 'listOpencodeModelsForCwd' diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2a7bc91209..b951f72f21 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -11,6 +11,7 @@ import type { FileSearchResponse, MachinesResponse, MessagesResponse, + CodexSessionsResponse, PermissionMode, PushSubscriptionPayload, PushUnsubscribePayload, @@ -538,14 +539,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 f19e311a81..9adb05589f 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 { useCursorModelsForMachine } from '@/hooks/queries/useCursorModelsForMachine' import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd' import { useSessions } from '@/hooks/queries/useSessions' @@ -37,6 +38,7 @@ import { MachineSelector } from './MachineSelector' import { ModelSelector } from './ModelSelector' import { OpencodeModelSelector } from './OpencodeModelSelector' import { ClaudeEffortSelector } from './ClaudeEffortSelector' +import { CodexSessionSelector } from './CodexSessionSelector' import { shouldEnableOpencodeModelDiscovery } from './opencodeModelsGate' import { ReasoningEffortSelector } from './ReasoningEffortSelector' import { @@ -76,6 +78,8 @@ export function NewSession(props: { const pendingCursorBaseRef = useRef(null) 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('') @@ -96,6 +100,9 @@ export function NewSession(props: { setModel('auto') setCursorSelectedBase('auto') } + if (agent !== 'codex') { + setSelectedCodexSessionId('') + } }, [agent]) useEffect(() => { @@ -181,6 +188,14 @@ 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 [opencodeSelectedModel, setOpencodeSelectedModel] = useState(null) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), @@ -405,6 +420,7 @@ export function NewSession(props: { setMachineId(newMachineId) setModel('auto') setCursorSelectedBase('auto') + setSelectedCodexSessionId('') const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -572,7 +588,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') { @@ -716,6 +733,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 10e3abf301..296db6e34f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -208,6 +208,10 @@ export default { 'newSession.opencodeModel.retry': 'Retry', 'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory', 'newSession.opencodeModel.default': 'Default', + '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 7c52ba47c1..c2db2bc613 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -211,6 +211,10 @@ export default { 'newSession.opencodeModel.retry': '重试', 'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型', 'newSession.opencodeModel.default': '默认', + '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 89d0cc0b24..808db8f4cc 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 f1b7c5fc1c..85f096f9b7 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -126,6 +126,22 @@ export type SkillsResponse = { 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 3f4f33dc981d134b0bc4ecd5c803422e539122d6 Mon Sep 17 00:00:00 2001 From: Ethan Wang Date: Mon, 27 Apr 2026 11:39:10 +0000 Subject: [PATCH 02/19] 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 | 242 +++++++++++++++++- cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/buildCliArgs.test.ts | 10 + cli/src/runner/run.ts | 3 + .../sync/permissionModePersistence.test.ts | 1 + hub/src/sync/rpcGateway.ts | 3 +- hub/src/sync/sessionModel.test.ts | 2 + hub/src/sync/syncEngine.ts | 3 + hub/src/web/routes/machines.ts | 1 + shared/src/apiTypes.ts | 3 +- 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 + 31 files changed, 1129 insertions(+), 23 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 a02e23ed6a..8d58188ce9 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -85,6 +85,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch transcriptPath, // 中文注释:导入模式下允许 scanner 首次回放 transcript 全量内容,补齐 Codex 客户端里已有但 Hapi 还未看到的消息。 replayExistingHistory: session.replayTranscriptHistoryOnStart, + replayExistingEvents: session.importHistory, onSessionId: (sessionId) => { if (!isPrimarySessionId(sessionId)) { logger.debug(`[codex-local]: Ignoring transcript session id ${sessionId}; primary is ${primarySessionId}`); 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 aad56be4a6..bbcad03b78 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -34,6 +34,7 @@ interface LoopOptions { collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; replayTranscriptHistoryOnStart?: boolean; + importHistory?: boolean; onSessionReady?: (session: CodexSession) => void; } @@ -58,7 +59,8 @@ export async function loop(opts: LoopOptions): Promise { model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, collaborationMode: opts.collaborationMode ?? 'default', - replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false + replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false, + importHistory: opts.importHistory }); await runLocalRemoteSession({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index bda6f08c53..6d011c3762 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -19,6 +19,7 @@ import type { ReasoningEffort } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; +import { importCodexSessionHistory } from './importHistory'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -29,6 +30,7 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + importHistory?: boolean; model?: string; modelReasoningEffort?: ReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; @@ -92,6 +94,31 @@ export async function runCodex(opts: { registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); + 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) { @@ -357,6 +384,7 @@ export async function runCodex(opts: { collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, replayTranscriptHistoryOnStart, + 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 c8892419d1..ed7fed8a64 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -40,6 +40,7 @@ export class CodexSession extends AgentSessionBase { modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; replayTranscriptHistoryOnStart?: boolean; + importHistory?: boolean; }) { super({ api: opts.api, @@ -71,8 +72,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 efd7394cc0..443a2579fe 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -196,10 +196,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 d6a7db8032..aac70063ae 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -80,6 +80,30 @@ describe('codexSessionScanner', () => { expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' }); }); + 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 06bd667014..136adf0323 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; replayExistingHistory?: boolean; @@ -33,6 +34,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 replayExistingHistoryOnNextAttach: boolean; @@ -44,6 +46,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.onEvent = opts.onEvent; this.onSessionId = opts.onSessionId; this.replayExistingHistoryOnNextAttach = opts.replayExistingHistory ?? false; + this.replayExistingEvents = opts.replayExistingEvents === true; } async setTranscriptPath(transcriptPath: string): Promise { @@ -105,6 +108,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 4ba87474a4..11edd07022 100644 --- a/cli/src/modules/common/codexSessions.ts +++ b/cli/src/modules/common/codexSessions.ts @@ -28,11 +28,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 +66,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[] = []; @@ -86,7 +115,29 @@ 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 +162,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 +196,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: Stats; - let content: string; + 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 +318,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 +339,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 +405,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 +426,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 5e243eb871..9245f0de09 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -5,6 +5,7 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + importHistory?: boolean approvedNewDirectoryCreation?: boolean agent?: AgentFlavor model?: string diff --git a/cli/src/runner/buildCliArgs.test.ts b/cli/src/runner/buildCliArgs.test.ts index 46fe6622fa..f1ba79f4ee 100644 --- a/cli/src/runner/buildCliArgs.test.ts +++ b/cli/src/runner/buildCliArgs.test.ts @@ -81,4 +81,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 51b6a974fa..705e22fee6 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -922,6 +922,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/permissionModePersistence.test.ts b/hub/src/sync/permissionModePersistence.test.ts index 396cda4b8b..afa38cfcb8 100644 --- a/hub/src/sync/permissionModePersistence.test.ts +++ b/hub/src/sync/permissionModePersistence.test.ts @@ -134,6 +134,7 @@ describe('permission mode persistence', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, _effort?: string, permissionMode?: string ) => { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index a11f4936cc..46da142c0f 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -131,6 +131,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 }> { @@ -138,7 +139,7 @@ export class RpcGateway { const result = await this.machineRpc( machineId, RPC_METHODS.SpawnHappySession, - { 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 4ec57f5a17..ee155408fc 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -615,6 +615,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _importHistory?: boolean, effort?: string ) => { capturedModel = model @@ -989,6 +990,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 99111ad9ad..9c64e70468 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -503,6 +503,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 }> { @@ -516,6 +517,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + importHistory, effort, permissionMode ) @@ -717,6 +719,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + false, session.effort ?? undefined, preferredPermissionMode ) diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index d9c349a831..9245fc623c 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -50,6 +50,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/apiTypes.ts b/shared/src/apiTypes.ts index d3160420d9..34fd227557 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -199,7 +199,8 @@ export const SpawnSessionRequestSchema = 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() }) export type SpawnSessionRequest = z.infer diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index f1087aeeea..c2cb21d8c4 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -25,6 +25,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(), @@ -56,7 +91,8 @@ export const MetadataSchema = z.object({ preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), - 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 b10060a40e..b9a4408b99 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, Machine, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b951f72f21..3d91287a13 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -540,11 +540,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 9adb05589f..55ee0f301a 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -589,7 +589,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 74dd6ce9ec..9bbf7539c4 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -15,6 +15,7 @@ type SpawnInput = { sessionType?: 'simple' | 'worktree' worktreeName?: string resumeSessionId?: string + importHistory?: boolean } export function useSpawnSession(api: ApiClient | null): { @@ -39,7 +40,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, From 2f32998b8509b7fe3eca901e7f29fe60d6f51912 Mon Sep 17 00:00:00 2001 From: Ethan Wang Date: Mon, 27 Apr 2026 11:40:17 +0000 Subject: [PATCH 03/19] add Codex usage indicator --- cli/src/codex/codexLocalLauncher.ts | 3 + cli/src/codex/codexRemoteLauncher.test.ts | 83 +++++++++- cli/src/codex/codexRemoteLauncher.ts | 113 ++++++++++++++ cli/src/codex/session.ts | 12 ++ .../codex/utils/appServerEventConverter.ts | 10 +- .../socket/handlers/cli/sessionHandlers.ts | 11 +- shared/src/codexUsageSchema.test.ts | 43 ++++++ shared/src/schemas.ts | 2 + .../AssistantChat/ComposerButtons.tsx | 145 +++++++++++++++--- .../AssistantChat/HappyComposer.tsx | 4 + .../AssistantChat/codexUsageDisplay.test.ts | 81 ++++++++++ .../AssistantChat/codexUsageDisplay.ts | 107 +++++++++++++ web/src/components/SessionChat.tsx | 1 + web/src/hooks/useSSE.ts | 2 +- 14 files changed, 588 insertions(+), 29 deletions(-) create mode 100644 shared/src/codexUsageSchema.test.ts create mode 100644 web/src/components/AssistantChat/codexUsageDisplay.test.ts create mode 100644 web/src/components/AssistantChat/codexUsageDisplay.ts diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 8d58188ce9..0e1e59eac8 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -106,6 +106,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch session.sendUserMessage(converted.userMessage); } if (converted?.message) { + if (converted.message.type === 'token_count') { + session.recordCodexUsage(converted.message); + } session.sendAgentMessage(converted.message); } } diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 28e931a166..07459e30bb 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -26,6 +26,10 @@ const harness = vi.hoisted(() => ({ suppressGoalNotifications: false, suppressTurnCompletion: false, remainingThreadSystemErrors: 0, + transcriptPathByThreadId: new Map(), + scannerStarts: [] as Array<{ transcriptPath: string | null; replayExistingEvents?: boolean }>, + scannerCleanups: 0, + scannerEvents: [] as Array<(event: unknown) => void>, startTurnMessages: [] as string[], failResumeThreadIds: [] as string[], nextThreadSystemErrorMessage: null as string | null, @@ -803,6 +807,30 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ } })); +vi.mock('@/modules/common/codexSessions', () => ({ + findCodexSessionFile: async (threadId: string) => harness.transcriptPathByThreadId.get(threadId) ?? `/tmp/${threadId}.jsonl` +})); + +vi.mock('./utils/codexSessionScanner', () => ({ + createCodexSessionScanner: async (opts: { + transcriptPath: string | null; + replayExistingEvents?: boolean; + onEvent: (event: unknown) => void; + }) => { + harness.scannerStarts.push({ + transcriptPath: opts.transcriptPath, + replayExistingEvents: opts.replayExistingEvents + }); + harness.scannerEvents.push(opts.onEvent); + return { + cleanup: async () => { + harness.scannerCleanups += 1; + }, + setTranscriptPath: async () => {} + }; + } +})); + import { codexRemoteLauncher } from './codexRemoteLauncher'; type FakeAgentState = { @@ -832,6 +860,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; const summaryMessages: unknown[] = []; + const usagePayloads: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; @@ -911,6 +940,9 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat }, sendUserMessage(text: string) { client.sendUserMessage(text); + }, + recordCodexUsage(payload: unknown) { + usagePayloads.push(payload); } }; @@ -929,7 +961,8 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat getModel: () => currentModel, getCollaborationMode: () => currentCollaborationMode, collaborationModes, - getAgentState: () => agentState + getAgentState: () => agentState, + usagePayloads }; } @@ -988,6 +1021,10 @@ describe('codexRemoteLauncher', () => { harness.emitCompletedChildTurnBeforeSuppressedParent = false; harness.emitTurnAbortedOnInterrupt = false; harness.bridgeOptions = []; + harness.transcriptPathByThreadId = new Map(); + harness.scannerStarts = []; + harness.scannerCleanups = 0; + harness.scannerEvents = []; }); it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => { @@ -2256,4 +2293,48 @@ describe('codexRemoteLauncher', () => { message: '/compact does not accept arguments' }); }); + + it('tails remote Codex transcript for usage without replaying transcript messages', async () => { + harness.transcriptPathByThreadId.set('thread-1', '/tmp/codex-thread-1.jsonl'); + const { session, codexMessages, usagePayloads } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.scannerStarts).toEqual([{ + transcriptPath: '/tmp/codex-thread-1.jsonl', + replayExistingEvents: true + }]); + + harness.scannerEvents[0]?.({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { total_tokens: 42000 }, + model_context_window: 128000 + } + } + }); + harness.scannerEvents[0]?.({ + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'transcript duplicate' + } + }); + + expect(usagePayloads).toHaveLength(1); + expect(usagePayloads[0]).toMatchObject({ + type: 'token_count', + info: { + total_token_usage: { total_tokens: 42000 }, + model_context_window: 128000 + } + }); + expect(codexMessages).not.toContainEqual(expect.objectContaining({ + message: 'transcript duplicate' + })); + expect(harness.scannerCleanups).toBe(1); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 2cb80fe17f..728a586803 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -15,11 +15,14 @@ import type { EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; import { AppServerEventConverter } from './utils/appServerEventConverter'; import { detectImageMimeType, registerGeneratedImage } from '@/modules/common/generatedImages'; +import { convertCodexEvent } from './utils/codexEventConverter'; +import { createCodexSessionScanner, type CodexSessionScanner } from './utils/codexSessionScanner'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; +import { findCodexSessionFile } from '@/modules/common/codexSessions'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -185,6 +188,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { private currentThreadId: string | null = null; private currentTurnId: string | null = null; private readonly activeChildTurns = new Map(); + private usageScanner: CodexSessionScanner | null = null; + private usageScannerThreadId: string | null = null; + private usageScannerSetup: Promise | null = null; + private shuttingDown = false; constructor(session: CodexSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -275,6 +282,104 @@ class CodexRemoteLauncher extends RemoteLauncherBase { await this.handleAbort(); } + private async ensureUsageScanner(threadId: string): Promise { + if (this.usageScannerThreadId === threadId && (this.usageScanner || this.usageScannerSetup)) { + return this.usageScannerSetup ?? Promise.resolve(); + } + + const setupTask = this.replaceUsageScanner(threadId); + this.usageScannerSetup = setupTask.finally(() => { + if (this.usageScannerSetup === setupTask) { + this.usageScannerSetup = null; + } + }); + return this.usageScannerSetup; + } + + private async replaceUsageScanner(threadId: string): Promise { + const previousScanner = this.usageScanner; + this.usageScanner = null; + this.usageScannerThreadId = threadId; + if (previousScanner) { + await previousScanner.cleanup(); + } + + const transcriptPath = await this.findTranscriptWithRetry(threadId); + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + return; + } + if (!transcriptPath) { + logger.debug(`[Codex] No transcript found for remote thread ${threadId}; usage unavailable`); + return; + } + + const scanner = await createCodexSessionScanner({ + transcriptPath, + replayExistingEvents: true, + onEvent: (event) => { + const converted = convertCodexEvent(event); + if (converted?.message?.type === 'token_count') { + this.session.recordCodexUsage(converted.message); + } + } + }); + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + await scanner.cleanup(); + return; + } + this.usageScanner = scanner; + } + + private async findTranscriptWithRetry(threadId: string): Promise { + const attempts = 6; + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (this.shuttingDown || this.usageScannerThreadId !== threadId) { + return null; + } + try { + const transcriptPath = await findCodexSessionFile(threadId); + if (transcriptPath) { + return transcriptPath; + } + } catch (error) { + logger.debug(`[Codex] Failed to find transcript for remote thread ${threadId}:`, error); + return null; + } + if (attempt < attempts - 1) { + await this.sleep(250); + } + } + return null; + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); + } + + private async cleanupUsageScanner(): Promise { + if (this.usageScannerSetup) { + try { + await this.usageScannerSetup; + } catch (error) { + logger.debug('[Codex] Remote usage scanner setup failed during cleanup:', error); + } + } + this.shuttingDown = true; + const scanner = this.usageScanner; + this.usageScanner = null; + this.usageScannerThreadId = null; + if (scanner) { + try { + await scanner.cleanup(); + } catch (error) { + logger.debug('[Codex] Remote usage scanner cleanup failed:', error); + } + } + } + public async launch(): Promise { if (this.session.codexArgs && this.session.codexArgs.length > 0) { if (hasCodexCliOverrides(this.session.codexCliOverrides)) { @@ -1972,6 +2077,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (!this.currentThreadId || this.currentThreadId === threadId) { this.currentThreadId = threadId; session.onSessionFound(threadId); + void this.ensureUsageScanner(threadId).catch((error) => { + logger.debug(`[Codex] Failed to start remote usage scanner for ${threadId}:`, error); + }); } else { logger.debug( `[Codex] Ignoring thread_started for non-active thread; ` + @@ -2270,6 +2378,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } if (msgType === 'token_count') { const threadId = eventThreadId ?? this.currentThreadId; + session.recordCodexUsage(msg); session.sendAgentMessage({ ...addCodexEventScope(msg, 'parent', threadId), id: randomUUID() @@ -3041,6 +3150,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { this.currentThreadId = threadId; session.onSessionFound(threadId); + void this.ensureUsageScanner(threadId).catch((error) => { + logger.debug(`[Codex] Failed to start remote usage scanner for ${threadId}:`, error); + }); hasThread = true; } else { if (!this.currentThreadId) { @@ -3162,6 +3274,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { protected async cleanup(): Promise { logger.debug('[codex-remote]: cleanup start'); this.appServerClient.setStderrHandler(null); + await this.cleanupUsageScanner(); try { await this.appServerClient.disconnect(); } catch (error) { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index ed7fed8a64..55b4199c83 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -5,6 +5,7 @@ import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { Metadata, SessionModel, SessionModelReasoningEffort } from '@/api/types'; +import { normalizeCodexUsage } from './utils/codexUsage'; type LocalLaunchFailure = { message: string; @@ -131,6 +132,17 @@ export class CodexSession extends AgentSessionBase { this.modelReasoningEffort = modelReasoningEffort; }; + recordCodexUsage = (payload: unknown): void => { + const codexUsage = normalizeCodexUsage(payload); + if (!codexUsage) { + return; + } + this.client.updateMetadata((metadata) => ({ + ...metadata, + codexUsage + })); + }; + setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { this.collaborationMode = mode; this.pushKeepAlive(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 28bae2be92..dff652e3d7 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -697,7 +697,15 @@ export class AppServerEventConverter { } if (method === 'thread/tokenUsage/updated') { - const info = asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {}; + const info = { + ...(asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {}) + }; + if (info.rate_limits === undefined && info.rateLimits === undefined) { + const rateLimits = paramsRecord.rate_limits ?? paramsRecord.rateLimits; + if (rateLimits !== undefined) { + info.rate_limits = rateLimits; + } + } events.push(scoped({ type: 'token_count', info })); return events; } diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 67def89cee..a0d238950d 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, Metadata, PermissionMode } from '@hapi/protocol/types' import { isRedundantGoalStatusEventContent } from '@hapi/protocol/messages' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' @@ -213,7 +213,14 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session } } socket.to(`session:${sid}`).emit('update', update) - onWebappEvent?.({ type: 'session-updated', sessionId: sid }) + onWebappEvent?.({ + type: 'session-updated', + sessionId: sid, + data: { + metadata: result.value as Metadata | null, + metadataVersion: result.version + } + }) } } diff --git a/shared/src/codexUsageSchema.test.ts b/shared/src/codexUsageSchema.test.ts new file mode 100644 index 0000000000..16b46a692a --- /dev/null +++ b/shared/src/codexUsageSchema.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { MetadataSchema } from './schemas' + +describe('MetadataSchema codexUsage', () => { + it('accepts structured Codex usage metadata', () => { + const parsed = MetadataSchema.safeParse({ + path: '/repo', + host: 'machine', + flavor: 'codex', + codexUsage: { + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2, + updatedAt: 1 + }, + rateLimits: { + fiveHour: { + usedPercent: 25, + windowMinutes: 300, + resetAt: 2 + } + }, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + }) + + expect(parsed.success).toBe(true) + expect(parsed.success ? parsed.data.codexUsage : undefined).toMatchObject({ + contextWindow: { + usedTokens: 2000, + limitTokens: 100_000, + percent: 2 + } + }) + }) +}) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index c2cb21d8c4..21fb51bc2e 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -254,6 +254,8 @@ export const SessionPatchSchema = z.object({ thinking: z.boolean().optional(), activeAt: z.number().optional(), updatedAt: z.number().optional(), + metadata: MetadataSchema.nullable().optional(), + metadataVersion: z.number().optional(), model: z.string().nullable().optional(), modelReasoningEffort: z.string().nullable().optional(), effort: z.string().nullable().optional(), diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 5b0325a6f6..da8223e72e 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,4 +1,6 @@ import { ComposerPrimitive } from '@assistant-ui/react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { CodexUsage } from '@hapi/protocol/types' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' import { ScheduleIcon } from '@/components/icons' @@ -6,7 +8,7 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' -import { useRef, useState } from 'react' +import { getCodexUsageRingPercent, getCodexUsageRows } from './codexUsageDisplay' function VoiceAssistantIcon() { return ( @@ -426,6 +428,97 @@ export function UnifiedButton(props: { ) } +function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { + const [open, setOpen] = useState(false) + const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) + const buttonRef = useRef(null) + const percent = getCodexUsageRingPercent(props.usage) + + const updatePosition = useCallback(() => { + const button = buttonRef.current + if (!button) return + const rect = button.getBoundingClientRect() + const width = 288 + const margin = 8 + const maxLeft = Math.max(margin, window.innerWidth - width - margin) + setPosition({ + left: Math.min(Math.max(margin, rect.right - width), maxLeft), + bottom: Math.max(margin, window.innerHeight - rect.top + margin) + }) + }, []) + + useLayoutEffect(() => { + if (!open) return + updatePosition() + window.addEventListener('resize', updatePosition) + window.addEventListener('scroll', updatePosition, true) + return () => { + window.removeEventListener('resize', updatePosition) + window.removeEventListener('scroll', updatePosition, true) + } + }, [open, updatePosition]) + + if (!props.usage || percent === null) { + return null + } + + const rows = getCodexUsageRows(props.usage) + const roundedPercent = Math.round(percent) + const isHighUsage = percent > 85 + const usageColor = isHighUsage ? '#991b1b' : 'var(--app-link)' + const textColor = isHighUsage ? '#991b1b' : 'var(--app-hint)' + const background = `conic-gradient(${usageColor} ${percent * 3.6}deg, var(--app-divider) 0deg)` + + return ( +
+ + {open && position ? ( +
+
+ Codex Usage +
+
+ {rows.map((row) => ( +
+
+
{row.label}
+ {row.detail ? ( +
{row.detail}
+ ) : null} +
+
{row.value}
+
+ ))} +
+
+ ) : null} +
+ ) +} + export function ComposerButtons(props: { canSend: boolean controlsDisabled: boolean @@ -464,6 +557,7 @@ export function ComposerButtons(props: { scratchlistMode?: boolean scratchlistCount?: number onScratchlistToggle?: () => void + codexUsage?: CodexUsage | null }) { const { t } = useTranslation() const isVoiceConnected = props.voiceStatus === 'connected' @@ -612,29 +706,32 @@ export function ComposerButtons(props: { ) : null} - +
+ + +
) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 96ae5753f4..1d1c21b05a 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -13,6 +13,7 @@ import { useState } from 'react' import type { AgentState, CodexCollaborationMode, PermissionMode, ThreadGoal } from '@/types/api' +import type { CodexUsage } from '@hapi/protocol/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -84,6 +85,7 @@ export function HappyComposer(props: { contextSize?: number contextCacheRead?: number contextWindow?: number | null + codexUsage?: CodexUsage | null controlledByUser?: boolean agentFlavor?: string | null availableModelOptions?: Array<{ value: string | null; label: string }> @@ -144,6 +146,7 @@ export function HappyComposer(props: { contextSize, contextCacheRead, contextWindow, + codexUsage, controlledByUser = false, agentFlavor, availableModelOptions, @@ -1041,6 +1044,7 @@ export function HappyComposer(props: { scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} + codexUsage={agentFlavor === 'codex' ? codexUsage : undefined} /> diff --git a/web/src/components/AssistantChat/codexUsageDisplay.test.ts b/web/src/components/AssistantChat/codexUsageDisplay.test.ts new file mode 100644 index 0000000000..cb9573e204 --- /dev/null +++ b/web/src/components/AssistantChat/codexUsageDisplay.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { CodexUsage } from '@hapi/protocol/types' +import { + formatCodexUsageReset, + getCodexUsageRingPercent, + getCodexUsageRows +} from './codexUsageDisplay' + +describe('codexUsageDisplay', () => { + it('prefers context window percent for the ring', () => { + const usage: CodexUsage = { + contextWindow: { + usedTokens: 20_000, + limitTokens: 100_000, + percent: 20, + updatedAt: 1 + }, + rateLimits: { + fiveHour: { + usedPercent: 80, + windowMinutes: 300 + } + } + } + + expect(getCodexUsageRingPercent(usage)).toBe(20) + }) + + it('falls back to the highest rate-limit usage for the ring', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { + usedPercent: 30, + windowMinutes: 300 + }, + weekly: { + usedPercent: 60, + windowMinutes: 10080 + } + } + } + + expect(getCodexUsageRingPercent(usage)).toBe(60) + }) + + it('formats detail rows and reset times', () => { + const usage: CodexUsage = { + contextWindow: { + usedTokens: 2_000, + limitTokens: 10_000, + percent: 20, + updatedAt: 1 + }, + rateLimits: { + fiveHour: { + usedPercent: 50, + windowMinutes: 300, + resetAt: Date.UTC(2026, 3, 27, 12, 0, 0) + } + }, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + + expect(getCodexUsageRows(usage).map((row) => row.label)).toEqual([ + 'Context Window', + '5h Usage', + 'Token Breakdown' + ]) + expect(formatCodexUsageReset(Date.UTC(2026, 3, 27, 12, 0, 0), 'en-US')).toMatch(/Apr 27/) + }) + + it('returns null when no usage is displayable', () => { + expect(getCodexUsageRingPercent({ rateLimits: {} })).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/codexUsageDisplay.ts b/web/src/components/AssistantChat/codexUsageDisplay.ts new file mode 100644 index 0000000000..3451cd302d --- /dev/null +++ b/web/src/components/AssistantChat/codexUsageDisplay.ts @@ -0,0 +1,107 @@ +import type { CodexTokenUsage, CodexUsage, CodexUsageRateLimit } from '@hapi/protocol/types' + +export type CodexUsageRow = { + label: string + value: string + detail?: string +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function formatPercent(value: number): string { + const clamped = clampPercent(value) + return `${clamped >= 10 ? Math.round(clamped) : Math.round(clamped * 10) / 10}%` +} + +function formatTokens(value: number): string { + if (Math.abs(value) >= 1000) { + return `${Math.round(value / 1000)}k` + } + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) +} + +function formatRateLimit(rateLimit: CodexUsageRateLimit): string { + return formatPercent(rateLimit.usedPercent) +} + +export function formatCodexUsageReset(resetAt: number | undefined, locale?: string): string | null { + if (!resetAt) { + return null + } + return new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }).format(new Date(resetAt)) +} + +function formatTokenBreakdown(usage: CodexTokenUsage): string { + return [ + `input ${formatTokens(usage.inputTokens)}`, + `cached ${formatTokens(usage.cachedInputTokens)}`, + `output ${formatTokens(usage.outputTokens)}`, + `reasoning ${formatTokens(usage.reasoningOutputTokens)}` + ].join(' · ') +} + +export function getCodexUsageRingPercent(usage: CodexUsage | null | undefined): number | null { + if (!usage) { + return null + } + if (usage.contextWindow) { + return clampPercent(usage.contextWindow.percent) + } + const candidates = [ + usage.rateLimits?.fiveHour?.usedPercent, + usage.rateLimits?.weekly?.usedPercent + ].filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + if (candidates.length === 0) { + return null + } + return clampPercent(Math.max(...candidates)) +} + +export function getCodexUsageRows(usage: CodexUsage, locale?: string): CodexUsageRow[] { + const rows: CodexUsageRow[] = [] + if (usage.contextWindow) { + rows.push({ + label: 'Context Window', + value: formatPercent(usage.contextWindow.percent), + detail: `${formatTokens(usage.contextWindow.usedTokens)} / ${formatTokens(usage.contextWindow.limitTokens)} tokens` + }) + } + if (usage.rateLimits?.fiveHour) { + const reset = formatCodexUsageReset(usage.rateLimits.fiveHour.resetAt, locale) + rows.push({ + label: '5h Usage', + value: formatRateLimit(usage.rateLimits.fiveHour), + detail: reset ? `resets ${reset}` : undefined + }) + } + if (usage.rateLimits?.weekly) { + const reset = formatCodexUsageReset(usage.rateLimits.weekly.resetAt, locale) + rows.push({ + label: '1 Week Usage', + value: formatRateLimit(usage.rateLimits.weekly), + detail: reset ? `resets ${reset}` : undefined + }) + } + if (usage.totalTokenUsage) { + rows.push({ + label: 'Token Breakdown', + value: formatTokens(usage.totalTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.totalTokenUsage) + }) + } + if (!usage.totalTokenUsage && usage.lastTokenUsage) { + rows.push({ + label: 'Last Turn Tokens', + value: formatTokens(usage.lastTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.lastTokenUsage) + }) + } + return rows +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3390ee7b07..4fbd018074 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1090,6 +1090,7 @@ function SessionChatInner(props: SessionChatProps) { contextSize={reduced.latestUsage?.contextSize} contextCacheRead={reduced.latestUsage?.cacheRead} contextWindow={reduced.latestUsage?.contextWindow} + codexUsage={agentFlavor === 'codex' ? props.session.metadata?.codexUsage : undefined} controlledByUser={controlledByUser} onCollaborationModeChange={ codexCollaborationModeSupported && props.session.active && !controlledByUser diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 11569e43a3..b25a91c8c1 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -64,7 +64,7 @@ function getSessionPatch(value: unknown): SessionPatch | null { if (!parsed.success) { return null } - return Object.keys(parsed.data).length > 0 ? parsed.data : null + return Object.keys(parsed.data).length > 0 ? (parsed.data as SessionPatch) : null } function isMachineRecord(value: unknown): value is Machine { From 753b51b84557783a694dbb22e7302096f0bc4c5d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:44:54 +0100 Subject: [PATCH 04/19] feat(codex-usage): surface credits + reached-type for premium accounts Ethan's indicator (tiann/hapi#537) was designed for time-window plans (plus / pro 5h+weekly). On Codex Pro accounts that exhaust the subscription windows AND any topped-up credits, the app-server emits rate_limits.primary=null + secondary=null + credits.has_credits=false + balance="0", and the indicator silently fell back to context-window- only - reading "80% context, plenty of room" while the account was actually blocked. Extend the data path end-to-end: shared/schemas - add CodexUsageCreditsSchema (hasCredits / unlimited / balance) and optional rateLimitReachedType / planType / limitId on CodexUsageSchema. JSON-only, no SCHEMA_VERSION bump. cli/codexUsage - normalize credits + reached_type + plan_type + limit_id from the rate_limits root regardless of whether primary / secondary are present. web/codexUsageDisplay - add isCodexUsageBlocked() helper; force ring to 100% and color red when blocked; render a critical-severity "Credits" row with $balance + 'subscription / top-up exhausted' detail; render a critical-severity "Limit Reached" header when codex sets rate_limit_reached_type. Unlimited credit accounts read "Unlimited" and stay green. Covered by 4 new cli tests (premium-credits shape from a real Codex Pro rollout, plus reached-type + unlimited-credits cases), 3 new web tests (blocked-state ring forcing, Limit Reached header, unlimited non-blocking), and 1 new shared schema test. Co-authored-by: Cursor --- cli/src/codex/utils/codexUsage.test.ts | 86 +++++++++++++++++++ cli/src/codex/utils/codexUsage.ts | 62 ++++++++++++- shared/src/codexUsageSchema.test.ts | 28 ++++++ shared/src/schemas.ts | 21 +++++ shared/src/types.ts | 1 + .../AssistantChat/ComposerButtons.tsx | 35 +++++--- .../AssistantChat/codexUsageDisplay.test.ts | 59 ++++++++++++- .../AssistantChat/codexUsageDisplay.ts | 62 +++++++++++++ 8 files changed, 339 insertions(+), 15 deletions(-) diff --git a/cli/src/codex/utils/codexUsage.test.ts b/cli/src/codex/utils/codexUsage.test.ts index 1212d55d16..785bfc45e9 100644 --- a/cli/src/codex/utils/codexUsage.test.ts +++ b/cli/src/codex/utils/codexUsage.test.ts @@ -133,4 +133,90 @@ describe('normalizeCodexUsage', () => { it('returns null when no supported usage fields are present', () => { expect(normalizeCodexUsage({ message: 'hello' })).toBeNull(); }); + + it('extracts credits + plan metadata for premium-credits accounts with both windows null', () => { + // Shape captured from a live Codex Pro account whose 5h subscription + // window AND topped-up credits are both exhausted (rollout JSONL, + // 2026-06-08). primary/secondary are explicitly null because the + // plan no longer bills by window; the constraint is credits.balance. + const usage = normalizeCodexUsage({ + info: { + model_context_window: 258_400, + total_token_usage: { + input_tokens: 51_733_893, + cached_input_tokens: 50_161_280, + output_tokens: 74_915, + reasoning_output_tokens: 27_228, + total_tokens: 51_808_808 + }, + last_token_usage: { + input_tokens: 206_333, + cached_input_tokens: 205_696, + output_tokens: 41, + reasoning_output_tokens: 0, + total_tokens: 206_374 + } + }, + rate_limits: { + limit_id: 'premium', + limit_name: null, + primary: null, + secondary: null, + credits: { + has_credits: false, + unlimited: false, + balance: '0' + }, + plan_type: null, + rate_limit_reached_type: null + } + }, { now: 3_000_000 }); + + expect(usage?.rateLimits.fiveHour).toBeUndefined(); + expect(usage?.rateLimits.weekly).toBeUndefined(); + expect(usage?.credits).toEqual({ + hasCredits: false, + unlimited: false, + balance: '0' + }); + expect(usage?.limitId).toBe('premium'); + // plan_type and rate_limit_reached_type were null in the captured + // shape - those should drop out instead of surfacing as 'null'. + expect(usage?.planType).toBeUndefined(); + expect(usage?.rateLimitReachedType).toBeUndefined(); + }); + + it('preserves rate_limit_reached_type when codex flags an explicit cap', () => { + const usage = normalizeCodexUsage({ + info: { model_context_window: 100_000 }, + rate_limits: { + limit_id: 'plus', + plan_type: 'plus', + primary: { used_percent: 100, window_minutes: 300 }, + secondary: { used_percent: 100, window_minutes: 10080 }, + credits: null, + rate_limit_reached_type: 'weekly' + } + }); + + expect(usage?.rateLimitReachedType).toBe('weekly'); + expect(usage?.planType).toBe('plus'); + expect(usage?.limitId).toBe('plus'); + expect(usage?.credits).toBeUndefined(); + }); + + it('surfaces a non-blocking unlimited credit balance without exhausting flags', () => { + const usage = normalizeCodexUsage({ + info: { model_context_window: 100_000 }, + rate_limits: { + credits: { has_credits: true, unlimited: true, balance: '0' } + } + }); + + expect(usage?.credits).toEqual({ + hasCredits: true, + unlimited: true, + balance: '0' + }); + }); }); diff --git a/cli/src/codex/utils/codexUsage.ts b/cli/src/codex/utils/codexUsage.ts index 8b2f356de3..633d937b55 100644 --- a/cli/src/codex/utils/codexUsage.ts +++ b/cli/src/codex/utils/codexUsage.ts @@ -1,4 +1,4 @@ -import type { CodexTokenUsage, CodexUsage, CodexUsageRateLimit } from '@hapi/protocol/types'; +import type { CodexTokenUsage, CodexUsage, CodexUsageCredits, CodexUsageRateLimit } from '@hapi/protocol/types'; type NormalizerOptions = { now?: number; @@ -112,6 +112,44 @@ function collectRateLimitCandidates(value: unknown): unknown[] { return []; } +function extractRateLimitsRoot(value: unknown): Record | null { + const record = asRecord(value); + if (!record) return null; + const direct = asRecord(record.rate_limits ?? record.rateLimits); + return direct ?? record; +} + +function normalizeCredits(value: unknown): CodexUsageCredits | undefined { + const record = asRecord(value); + if (!record) return undefined; + + const hasCreditsRaw = record.has_credits ?? record.hasCredits; + const unlimitedRaw = record.unlimited; + const balanceRaw = record.balance; + + const hasCredits = typeof hasCreditsRaw === 'boolean' ? hasCreditsRaw : undefined; + const unlimited = typeof unlimitedRaw === 'boolean' ? unlimitedRaw : undefined; + let balance: string | undefined; + if (typeof balanceRaw === 'string' && balanceRaw.length > 0) { + balance = balanceRaw; + } else if (typeof balanceRaw === 'number' && Number.isFinite(balanceRaw)) { + balance = String(balanceRaw); + } + + if (hasCredits === undefined && unlimited === undefined && balance === undefined) { + return undefined; + } + return { + ...(hasCredits !== undefined ? { hasCredits } : {}), + ...(unlimited !== undefined ? { unlimited } : {}), + ...(balance !== undefined ? { balance } : {}) + }; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +} + function unwrapUsagePayload(value: unknown): Record | null { const record = asRecord(value); if (!record) return null; @@ -170,6 +208,14 @@ export function normalizeCodexUsage(value: unknown, options: NormalizerOptions = } } + const rateLimitsRoot = extractRateLimitsRoot(record); + const credits = normalizeCredits(rateLimitsRoot?.credits); + const rateLimitReachedType = asNonEmptyString( + rateLimitsRoot?.rate_limit_reached_type ?? rateLimitsRoot?.rateLimitReachedType + ); + const planType = asNonEmptyString(rateLimitsRoot?.plan_type ?? rateLimitsRoot?.planType); + const limitId = asNonEmptyString(rateLimitsRoot?.limit_id ?? rateLimitsRoot?.limitId); + const contextWindow = contextLimit !== null && contextLimit > 0 && contextUsed !== null ? { usedTokens: contextUsed, @@ -179,13 +225,25 @@ export function normalizeCodexUsage(value: unknown, options: NormalizerOptions = } : undefined; - if (!contextWindow && !totalTokenUsage && !lastTokenUsage && !rateLimits.fiveHour && !rateLimits.weekly) { + if ( + !contextWindow + && !totalTokenUsage + && !lastTokenUsage + && !rateLimits.fiveHour + && !rateLimits.weekly + && !credits + && !rateLimitReachedType + ) { return null; } return { ...(contextWindow ? { contextWindow } : {}), rateLimits, + ...(credits ? { credits } : {}), + ...(rateLimitReachedType ? { rateLimitReachedType } : {}), + ...(planType ? { planType } : {}), + ...(limitId ? { limitId } : {}), ...(totalTokenUsage ? { totalTokenUsage } : {}), ...(lastTokenUsage ? { lastTokenUsage } : {}) }; diff --git a/shared/src/codexUsageSchema.test.ts b/shared/src/codexUsageSchema.test.ts index 16b46a692a..0ee1078ffb 100644 --- a/shared/src/codexUsageSchema.test.ts +++ b/shared/src/codexUsageSchema.test.ts @@ -40,4 +40,32 @@ describe('MetadataSchema codexUsage', () => { } }) }) + + it('accepts premium-credits Codex metadata (credits + reached-type + plan fields)', () => { + const parsed = MetadataSchema.safeParse({ + path: '/repo', + host: 'machine', + flavor: 'codex', + codexUsage: { + contextWindow: { + usedTokens: 206_000, + limitTokens: 258_400, + percent: 80, + updatedAt: 1 + }, + rateLimits: {}, + credits: { hasCredits: false, unlimited: false, balance: '0' }, + rateLimitReachedType: 'weekly', + planType: 'pro', + limitId: 'premium' + } + }) + + expect(parsed.success).toBe(true) + const codexUsage = parsed.success ? parsed.data.codexUsage : undefined + expect(codexUsage?.credits).toEqual({ hasCredits: false, unlimited: false, balance: '0' }) + expect(codexUsage?.rateLimitReachedType).toBe('weekly') + expect(codexUsage?.planType).toBe('pro') + expect(codexUsage?.limitId).toBe('premium') + }) }) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 21fb51bc2e..acf4db1b78 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -43,6 +43,19 @@ export const CodexUsageRateLimitSchema = z.object({ export type CodexUsageRateLimit = z.infer +// Credit-based plans (e.g. Codex Pro on `limit_id: premium`) bill from a +// balance instead of a 5h/weekly rolling window. When the subscription +// is also exhausted the codex transcript shows primary=null + secondary=null +// + credits.has_credits=false; the indicator needs to surface that +// distinctly from a fresh-account "no rate-limit data yet" state. +export const CodexUsageCreditsSchema = z.object({ + hasCredits: z.boolean().optional(), + unlimited: z.boolean().optional(), + balance: z.string().optional() +}) + +export type CodexUsageCredits = z.infer + export const CodexUsageSchema = z.object({ contextWindow: z.object({ usedTokens: z.number(), @@ -54,6 +67,14 @@ export const CodexUsageSchema = z.object({ fiveHour: CodexUsageRateLimitSchema.optional(), weekly: CodexUsageRateLimitSchema.optional() }).optional().default({}), + credits: CodexUsageCreditsSchema.optional(), + // Codex surfaces 'primary' / 'secondary' / 'credits' as the canonical + // names. Carry through as-is so the UI can render the exact phrase + // (e.g. 'You have exceeded your weekly limit', or 'You have run out + // of credits') without re-deriving from the boolean state. + rateLimitReachedType: z.string().optional(), + planType: z.string().optional(), + limitId: z.string().optional(), totalTokenUsage: CodexTokenUsageSchema.optional(), lastTokenUsage: CodexTokenUsageSchema.optional() }) diff --git a/shared/src/types.ts b/shared/src/types.ts index b9a4408b99..db8166a13f 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -5,6 +5,7 @@ export type { AttachmentMetadata, CodexTokenUsage, CodexUsage, + CodexUsageCredits, CodexUsageRateLimit, DecryptedMessage, Metadata, diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index da8223e72e..51d868ace4 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,7 +8,7 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' -import { getCodexUsageRingPercent, getCodexUsageRows } from './codexUsageDisplay' +import { getCodexUsageRingPercent, getCodexUsageRows, isCodexUsageBlocked } from './codexUsageDisplay' function VoiceAssistantIcon() { return ( @@ -464,7 +464,13 @@ function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { const rows = getCodexUsageRows(props.usage) const roundedPercent = Math.round(percent) - const isHighUsage = percent > 85 + const blocked = isCodexUsageBlocked(props.usage) + // Blocked beats high-usage threshold: a Pro account with primary=null, + // secondary=null AND credits.balance="0" should read full red even + // though percent (forced to 100 upstream) would already trip the >85 + // threshold; this avoids a future change to that threshold accidentally + // demoting the blocked state. + const isHighUsage = blocked || percent > 85 const usageColor = isHighUsage ? '#991b1b' : 'var(--app-link)' const textColor = isHighUsage ? '#991b1b' : 'var(--app-hint)' const background = `conic-gradient(${usageColor} ${percent * 3.6}deg, var(--app-divider) 0deg)` @@ -501,17 +507,22 @@ function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { Codex Usage
- {rows.map((row) => ( -
-
-
{row.label}
- {row.detail ? ( -
{row.detail}
- ) : null} + {rows.map((row) => { + const critical = row.severity === 'critical' + const labelColor = critical ? '#991b1b' : 'var(--app-fg)' + const valueColor = critical ? '#991b1b' : 'var(--app-fg)' + return ( +
+
+
{row.label}
+ {row.detail ? ( +
{row.detail}
+ ) : null} +
+
{row.value}
-
{row.value}
-
- ))} + ) + })}
) : null} diff --git a/web/src/components/AssistantChat/codexUsageDisplay.test.ts b/web/src/components/AssistantChat/codexUsageDisplay.test.ts index cb9573e204..5f81f358ce 100644 --- a/web/src/components/AssistantChat/codexUsageDisplay.test.ts +++ b/web/src/components/AssistantChat/codexUsageDisplay.test.ts @@ -3,7 +3,8 @@ import type { CodexUsage } from '@hapi/protocol/types' import { formatCodexUsageReset, getCodexUsageRingPercent, - getCodexUsageRows + getCodexUsageRows, + isCodexUsageBlocked } from './codexUsageDisplay' describe('codexUsageDisplay', () => { @@ -78,4 +79,60 @@ describe('codexUsageDisplay', () => { it('returns null when no usage is displayable', () => { expect(getCodexUsageRingPercent({ rateLimits: {} })).toBeNull() }) + + it('forces 100% ring + critical Credits row when subscription + credits both exhausted', () => { + // Pro account, 5h + weekly windows both depleted (primary/secondary + // become null upstream), credits topped-up but spent to 0. Without + // this branch the ring would read context-window-only (e.g. 80%) + // and silently misrepresent "blocked" as "plenty of room left". + const usage: CodexUsage = { + contextWindow: { + usedTokens: 206_000, + limitTokens: 258_400, + percent: 80, + updatedAt: 1 + }, + rateLimits: {}, + credits: { hasCredits: false, unlimited: false, balance: '0' }, + limitId: 'premium' + } + + expect(isCodexUsageBlocked(usage)).toBe(true) + expect(getCodexUsageRingPercent(usage)).toBe(100) + + const rows = getCodexUsageRows(usage) + const creditsRow = rows.find((row) => row.label === 'Credits') + expect(creditsRow?.value).toBe('$0') + expect(creditsRow?.severity).toBe('critical') + expect(creditsRow?.detail).toBe('subscription / top-up exhausted') + }) + + it('renders Limit Reached header when codex emits rate_limit_reached_type', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 } + }, + rateLimitReachedType: 'weekly' + } + + const rows = getCodexUsageRows(usage) + expect(rows[0]).toEqual(expect.objectContaining({ + label: 'Limit Reached', + value: 'Weekly', + severity: 'critical' + })) + expect(isCodexUsageBlocked(usage)).toBe(true) + }) + + it('does not flag blocked when credits.unlimited is true even if balance reads 0', () => { + const usage: CodexUsage = { + rateLimits: {}, + credits: { hasCredits: true, unlimited: true, balance: '0' } + } + expect(isCodexUsageBlocked(usage)).toBe(false) + const rows = getCodexUsageRows(usage) + const creditsRow = rows.find((row) => row.label === 'Credits') + expect(creditsRow?.value).toBe('Unlimited') + expect(creditsRow?.severity).toBeUndefined() + }) }) diff --git a/web/src/components/AssistantChat/codexUsageDisplay.ts b/web/src/components/AssistantChat/codexUsageDisplay.ts index 3451cd302d..e3b3954f85 100644 --- a/web/src/components/AssistantChat/codexUsageDisplay.ts +++ b/web/src/components/AssistantChat/codexUsageDisplay.ts @@ -4,6 +4,35 @@ export type CodexUsageRow = { label: string value: string detail?: string + severity?: 'critical' | 'warn' +} + +// Subscription-and-credits exhausted: codex sends primary=null + +// secondary=null + credits.has_credits=false. The indicator should treat +// this as a "you are blocked, full red" state so users with a Pro +// subscription that ALSO topped up credits don't get a silent 80% ring. +export function isCodexUsageBlocked(usage: CodexUsage | null | undefined): boolean { + if (!usage) return false + if (usage.credits?.unlimited) return false + const hasCreditsExplicitlyFalse = usage.credits?.hasCredits === false + const balanceZero = usage.credits?.balance === '0' || usage.credits?.balance === '0.00' + const noTimeWindows = !usage.rateLimits?.fiveHour && !usage.rateLimits?.weekly + const reachedType = typeof usage.rateLimitReachedType === 'string' && usage.rateLimitReachedType.length > 0 + return reachedType || (noTimeWindows && (hasCreditsExplicitlyFalse || balanceZero)) +} + +function formatCreditsValue(credits: NonNullable): string { + if (credits.unlimited) return 'Unlimited' + if (credits.balance !== undefined) return `$${credits.balance}` + if (credits.hasCredits === false) return 'Out' + if (credits.hasCredits === true) return 'Available' + return '-' +} + +function formatRateLimitReachedType(value: string): string { + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (ch) => ch.toUpperCase()) } function clampPercent(value: number): number { @@ -51,6 +80,12 @@ export function getCodexUsageRingPercent(usage: CodexUsage | null | undefined): if (!usage) { return null } + // Blocked accounts (subscription window AND credits both exhausted, or + // explicit rate_limit_reached_type) must read 100% so the ring stops + // misrepresenting the state as 'context window 80%, plenty of room'. + if (isCodexUsageBlocked(usage)) { + return 100 + } if (usage.contextWindow) { return clampPercent(usage.contextWindow.percent) } @@ -66,6 +101,13 @@ export function getCodexUsageRingPercent(usage: CodexUsage | null | undefined): export function getCodexUsageRows(usage: CodexUsage, locale?: string): CodexUsageRow[] { const rows: CodexUsageRow[] = [] + if (usage.rateLimitReachedType) { + rows.push({ + label: 'Limit Reached', + value: formatRateLimitReachedType(usage.rateLimitReachedType), + severity: 'critical' + }) + } if (usage.contextWindow) { rows.push({ label: 'Context Window', @@ -89,6 +131,26 @@ export function getCodexUsageRows(usage: CodexUsage, locale?: string): CodexUsag detail: reset ? `resets ${reset}` : undefined }) } + // Surface credit-billing state when codex reports it - either an + // unlimited flag, a hard balance, or an explicit has_credits=false. + // Subscription-and-credits-exhausted accounts (Pro + top-up both at + // zero) get a critical severity so the row is visually distinct + // from a normal "5h Usage 50%" entry. + if (usage.credits) { + const balanceZero = usage.credits.balance === '0' || usage.credits.balance === '0.00' + const exhausted = usage.credits.hasCredits === false || balanceZero + const severity = !usage.credits.unlimited && exhausted ? 'critical' : undefined + rows.push({ + label: 'Credits', + value: formatCreditsValue(usage.credits), + detail: usage.credits.unlimited + ? 'unlimited' + : exhausted + ? 'subscription / top-up exhausted' + : undefined, + ...(severity ? { severity } : {}) + }) + } if (usage.totalTokenUsage) { rows.push({ label: 'Token Breakdown', From c448e5377fd230a1bbfeca135d978fc270f7f680 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:12:53 +0100 Subject: [PATCH 05/19] fix(codex-usage): drop $ prefix + format credit balance Codex sends 'balance' as a precision-preserving string ('250.0000000000', '0', '0.0000000000') with no declared unit. The previous render asserted USD with a $ prefix and dumped the string verbatim, producing the visually awful '$250.0000000000'. Credits are an internal billing token per the OpenAI Codex rate card (https://help.openai.com/en/articles/20001106-codex-rate-card): GPT-5.5 consumes 125 credits per 1M input tokens / 750 per 1M output, and a $5 top-up grants 125 credits (~$0.04/credit, not the $1/credit the prior comment fabricated). Chatgpt.com's own UI even renders credits and any USD conversion separately ('246 credits, ~10-62 cloud messages'), so prefixing $ on the balance is a flat-out type error. - formatCreditsBalance(): Number-parse + toLocaleString with 2dp cap for values >= 1 (4dp for sub-unit balances), trailing zeros trimmed. '250.0000000000' -> '250', '12.345' -> '12.35', '0.0000000000' -> '0'. - Drop the $ prefix; the row label 'Credits' carries the unit. - isCodexUsageBlocked / exhausted-detail check both now parse balance numerically instead of literal-matching '0' / '0.00', so future trailing-zero variants ('0.0000000000') cannot slip past. Adds a 4-case parametrized test covering the real string shapes observed in the wild plus a decimal-rounding case. Co-authored-by: Cursor --- .../AssistantChat/codexUsageDisplay.test.ts | 25 +++++++++++- .../AssistantChat/codexUsageDisplay.ts | 38 +++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/web/src/components/AssistantChat/codexUsageDisplay.test.ts b/web/src/components/AssistantChat/codexUsageDisplay.test.ts index 5f81f358ce..dd838d3c99 100644 --- a/web/src/components/AssistantChat/codexUsageDisplay.test.ts +++ b/web/src/components/AssistantChat/codexUsageDisplay.test.ts @@ -102,11 +102,34 @@ describe('codexUsageDisplay', () => { const rows = getCodexUsageRows(usage) const creditsRow = rows.find((row) => row.label === 'Credits') - expect(creditsRow?.value).toBe('$0') + expect(creditsRow?.value).toBe('0') expect(creditsRow?.severity).toBe('critical') expect(creditsRow?.detail).toBe('subscription / top-up exhausted') }) + it('formats credit balances without $ prefix and trims trailing zeros', () => { + // Codex sends balance as a precision-preserving string. Real + // payloads observed: '0' (empty), '250.0000000000' (just topped + // up $250), '0.0000000000' (also empty). All three must render + // cleanly without a $ prefix or trailing zero noise. + const cases: Array<{ raw: string; expected: string; blocked: boolean }> = [ + { raw: '0', expected: '0', blocked: true }, + { raw: '0.0000000000', expected: '0', blocked: true }, + { raw: '250.0000000000', expected: '250', blocked: false }, + { raw: '12.345', expected: '12.35', blocked: false } + ] + for (const { raw, expected, blocked } of cases) { + const usage: CodexUsage = { + rateLimits: {}, + credits: { hasCredits: !blocked, unlimited: false, balance: raw } + } + const rows = getCodexUsageRows(usage) + const creditsRow = rows.find((row) => row.label === 'Credits') + expect(creditsRow?.value, `balance="${raw}"`).toBe(expected) + expect(isCodexUsageBlocked(usage), `balance="${raw}"`).toBe(blocked) + } + }) + it('renders Limit Reached header when codex emits rate_limit_reached_type', () => { const usage: CodexUsage = { rateLimits: { diff --git a/web/src/components/AssistantChat/codexUsageDisplay.ts b/web/src/components/AssistantChat/codexUsageDisplay.ts index e3b3954f85..ab612bbf4e 100644 --- a/web/src/components/AssistantChat/codexUsageDisplay.ts +++ b/web/src/components/AssistantChat/codexUsageDisplay.ts @@ -7,6 +7,17 @@ export type CodexUsageRow = { severity?: 'critical' | 'warn' } +// Codex sends balance as a precision-preserving string ('250.0000000000', +// '0', '0.0000000000'). Number() handles all of those uniformly without +// risking a literal-match miss on a new trailing-zero variant. +function parseCreditsBalance(raw: string | undefined): number | null { + if (raw === undefined) return null + const trimmed = raw.trim() + if (trimmed.length === 0) return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null +} + // Subscription-and-credits exhausted: codex sends primary=null + // secondary=null + credits.has_credits=false. The indicator should treat // this as a "you are blocked, full red" state so users with a Pro @@ -15,15 +26,35 @@ export function isCodexUsageBlocked(usage: CodexUsage | null | undefined): boole if (!usage) return false if (usage.credits?.unlimited) return false const hasCreditsExplicitlyFalse = usage.credits?.hasCredits === false - const balanceZero = usage.credits?.balance === '0' || usage.credits?.balance === '0.00' + const parsedBalance = parseCreditsBalance(usage.credits?.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 const noTimeWindows = !usage.rateLimits?.fiveHour && !usage.rateLimits?.weekly const reachedType = typeof usage.rateLimitReachedType === 'string' && usage.rateLimitReachedType.length > 0 return reachedType || (noTimeWindows && (hasCreditsExplicitlyFalse || balanceZero)) } +// Codex's protocol field is 'balance' with no declared unit. Credits +// are an internal billing token consumed at token-mix-dependent rates +// (per the OpenAI Codex rate card, GPT-5.5 burns 125 credits per 1M +// input tokens / 750 per 1M output, and a $5 top-up grants 125 credits +// ~ $0.04/credit). Render as a bare count; the row label 'Credits' +// carries the unit and any USD conversion belongs in chatgpt.com's +// billing UI, not the indicator. See +// https://help.openai.com/en/articles/20001106-codex-rate-card +function formatCreditsBalance(raw: string): string { + const n = parseCreditsBalance(raw) + if (n === null) return raw + if (n === 0) return '0' + const decimals = Math.abs(n) >= 1 ? 2 : 4 + return n.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals + }) +} + function formatCreditsValue(credits: NonNullable): string { if (credits.unlimited) return 'Unlimited' - if (credits.balance !== undefined) return `$${credits.balance}` + if (credits.balance !== undefined) return formatCreditsBalance(credits.balance) if (credits.hasCredits === false) return 'Out' if (credits.hasCredits === true) return 'Available' return '-' @@ -137,7 +168,8 @@ export function getCodexUsageRows(usage: CodexUsage, locale?: string): CodexUsag // zero) get a critical severity so the row is visually distinct // from a normal "5h Usage 50%" entry. if (usage.credits) { - const balanceZero = usage.credits.balance === '0' || usage.credits.balance === '0.00' + const parsedBalance = parseCreditsBalance(usage.credits.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 const exhausted = usage.credits.hasCredits === false || balanceZero const severity = !usage.credits.unlimited && exhausted ? 'critical' : undefined rows.push({ From b0fad40ef21de03e8a926045e5c9ccc8e7091c38 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:21:29 +0100 Subject: [PATCH 06/19] fix(codex-usage): ring shows worst constraint, not just context Ethan's ring (PR #537) preferred contextWindow.percent and only fell back to rate-limits when context was absent. Real consequence: weekly=100% but ctx=80% rendered as a green '80' ring, hiding a HARD subscription cap behind a SOFT context fill. Then when both windows AND credits exhausted, the blocked override jumped the ring to red 100 - so the same circle silently switched semantics from 'context fill' to 'usage exhaustion' mid-session. Operator caught it: 'the circle that WAS showing you context now shows you red 100 meaning no more usage'. Make the ring mean one thing across all states: 'percent of the most-pressing limit you're about to hit'. - New getCodexUsageRing(): max across context + 5h + weekly (blocked still forces 100). Returns { percent, axis } so callers can show which constraint is in front. - getCodexUsageRingPercent() kept as a thin wrapper for any future callers that only need the number. - getCodexUsageRingTitle(): axis-aware aria-label + title so hovering the ring tells you 'Weekly subscription window 100% used' instead of 'Codex usage' regardless of state. - getCodexUsageRows() marks the dominant row (dominant: true). Popover paints a left-accent bar + bolds the matching label so opening it immediately answers 'why is the ring at 100?'. - Ring colour gains an amber intermediate (60-85%) instead of jumping straight from blue to red at 85. Replaces the 'prefers context' + 'falls back to rate-limits' tests with three new ones covering the bug shape (ctx 80 vs weekly 100), the inverse (context dominates), and dominant-row marking. Co-authored-by: Cursor --- .../AssistantChat/ComposerButtons.tsx | 38 +++++-- .../AssistantChat/codexUsageDisplay.test.ts | 70 ++++++++---- .../AssistantChat/codexUsageDisplay.ts | 103 ++++++++++++++---- 3 files changed, 160 insertions(+), 51 deletions(-) diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 51d868ace4..380c569009 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,7 +8,7 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' -import { getCodexUsageRingPercent, getCodexUsageRows, isCodexUsageBlocked } from './codexUsageDisplay' +import { getCodexUsageRing, getCodexUsageRingTitle, getCodexUsageRows, isCodexUsageBlocked } from './codexUsageDisplay' function VoiceAssistantIcon() { return ( @@ -432,7 +432,7 @@ function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { const [open, setOpen] = useState(false) const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) const buttonRef = useRef(null) - const percent = getCodexUsageRingPercent(props.usage) + const ring = getCodexUsageRing(props.usage) const updatePosition = useCallback(() => { const button = buttonRef.current @@ -458,11 +458,12 @@ function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { } }, [open, updatePosition]) - if (!props.usage || percent === null) { + if (!props.usage || ring === null) { return null } const rows = getCodexUsageRows(props.usage) + const percent = ring.percent const roundedPercent = Math.round(percent) const blocked = isCodexUsageBlocked(props.usage) // Blocked beats high-usage threshold: a Pro account with primary=null, @@ -471,17 +472,19 @@ function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { // threshold; this avoids a future change to that threshold accidentally // demoting the blocked state. const isHighUsage = blocked || percent > 85 - const usageColor = isHighUsage ? '#991b1b' : 'var(--app-link)' - const textColor = isHighUsage ? '#991b1b' : 'var(--app-hint)' + const isAmber = !isHighUsage && percent > 60 + const usageColor = isHighUsage ? '#991b1b' : isAmber ? '#b45309' : 'var(--app-link)' + const textColor = isHighUsage ? '#991b1b' : isAmber ? '#b45309' : 'var(--app-hint)' const background = `conic-gradient(${usageColor} ${percent * 3.6}deg, var(--app-divider) 0deg)` + const ringTitle = getCodexUsageRingTitle(ring, props.usage) return (
+ {open && position ? ( +
+
+ {props.popoverTitle ?? 'Agent Budget'} +
+
{tooltip}
+
+ {state.axes.map((axis) => { + const isDominant = state.dominantAxisId === axis.id + const isCritical = axis.critical === true + const isCovering = axis.covering === true + const labelColor = isCritical ? '#991b1b' : 'var(--app-fg)' + const valueColor = isCritical ? '#991b1b' : 'var(--app-fg)' + const borderColor = isCritical + ? '#991b1b' + : isDominant + ? palette.accent + : isCovering + ? 'var(--app-link)' + : 'transparent' + const emphasised = isCritical || isDominant + return ( +
+
+
{axis.label}
+ {axis.detail ? ( +
{axis.detail}
+ ) : null} +
+
{axis.valueText}
+
+ ) + })} + {state.metadata && state.metadata.length > 0 ? ( +
+ {state.metadata.map((row) => ( +
+
+
{row.label}
+ {row.detail ? ( +
{row.detail}
+ ) : null} +
+
{row.value}
+
+ ))} +
+ ) : null} +
+
+ ) : null} +
+ ) +} diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 380c569009..78f4fc9865 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,7 +8,8 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' -import { getCodexUsageRing, getCodexUsageRingTitle, getCodexUsageRows, isCodexUsageBlocked } from './codexUsageDisplay' +import { AgentBudgetIndicator } from './AgentBudgetIndicator' +import { toCodexBudgetState } from './codexBudgetAdapter' function VoiceAssistantIcon() { return ( @@ -429,125 +430,8 @@ export function UnifiedButton(props: { } function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { - const [open, setOpen] = useState(false) - const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) - const buttonRef = useRef(null) - const ring = getCodexUsageRing(props.usage) - - const updatePosition = useCallback(() => { - const button = buttonRef.current - if (!button) return - const rect = button.getBoundingClientRect() - const width = 288 - const margin = 8 - const maxLeft = Math.max(margin, window.innerWidth - width - margin) - setPosition({ - left: Math.min(Math.max(margin, rect.right - width), maxLeft), - bottom: Math.max(margin, window.innerHeight - rect.top + margin) - }) - }, []) - - useLayoutEffect(() => { - if (!open) return - updatePosition() - window.addEventListener('resize', updatePosition) - window.addEventListener('scroll', updatePosition, true) - return () => { - window.removeEventListener('resize', updatePosition) - window.removeEventListener('scroll', updatePosition, true) - } - }, [open, updatePosition]) - - if (!props.usage || ring === null) { - return null - } - - const rows = getCodexUsageRows(props.usage) - const percent = ring.percent - const roundedPercent = Math.round(percent) - const blocked = isCodexUsageBlocked(props.usage) - // Blocked beats high-usage threshold: a Pro account with primary=null, - // secondary=null AND credits.balance="0" should read full red even - // though percent (forced to 100 upstream) would already trip the >85 - // threshold; this avoids a future change to that threshold accidentally - // demoting the blocked state. - const isHighUsage = blocked || percent > 85 - const isAmber = !isHighUsage && percent > 60 - const usageColor = isHighUsage ? '#991b1b' : isAmber ? '#b45309' : 'var(--app-link)' - const textColor = isHighUsage ? '#991b1b' : isAmber ? '#b45309' : 'var(--app-hint)' - const background = `conic-gradient(${usageColor} ${percent * 3.6}deg, var(--app-divider) 0deg)` - const ringTitle = getCodexUsageRingTitle(ring, props.usage) - - return ( -
- - {open && position ? ( -
-
- Codex Usage -
-
- {rows.map((row) => { - const critical = row.severity === 'critical' - const dominant = row.dominant === true - const labelColor = critical ? '#991b1b' : 'var(--app-fg)' - const valueColor = critical ? '#991b1b' : 'var(--app-fg)' - const emphasised = critical || dominant - // Dominant row carries a subtle left accent bar - // that points the eye at the axis driving the - // ring percent (e.g. weekly 100% when the ring - // reads 100). Avoids the previous reading where - // 'why is the ring at X?' required mental - // cross-reference of 4 popover rows. - const borderColor = critical - ? '#991b1b' - : dominant - ? 'var(--app-link)' - : 'transparent' - return ( -
-
-
{row.label}
- {row.detail ? ( -
{row.detail}
- ) : null} -
-
{row.value}
-
- ) - })} -
-
- ) : null} -
- ) + const state = toCodexBudgetState(props.usage) + return } export function ComposerButtons(props: { diff --git a/web/src/components/AssistantChat/codexBudgetAdapter.test.ts b/web/src/components/AssistantChat/codexBudgetAdapter.test.ts new file mode 100644 index 0000000000..df6f7a7b99 --- /dev/null +++ b/web/src/components/AssistantChat/codexBudgetAdapter.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import type { CodexUsage } from '@hapi/protocol/types' +import { toCodexBudgetState } from './codexBudgetAdapter' + +describe('toCodexBudgetState', () => { + it('returns null when usage is empty', () => { + expect(toCodexBudgetState(null)).toBeNull() + expect(toCodexBudgetState(undefined)).toBeNull() + expect(toCodexBudgetState({ rateLimits: {} })).toBeNull() + }) + + it('builds a healthy state from a fresh Plus account', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 30, windowMinutes: 300 }, + weekly: { usedPercent: 10, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('green') + expect(state?.operationalAxisId).toBe('context') + expect(state?.dominantAxisId).toBe('fiveHour') + expect(state?.axes.map((axis) => axis.id)).toEqual(['context', 'fiveHour', 'weekly']) + }) + + it('flags amber when context fills past 60% with no rate-limit pressure', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 70_000, limitTokens: 100_000, percent: 70, updatedAt: 1 }, + rateLimits: {} + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('amber') + expect(state?.dominantAxisId).toBe('context') + }) + + it('flags red when any axis crosses 95% (subscription cap with no credits to cover)', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 5, windowMinutes: 300 }, + weekly: { usedPercent: 96, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('red') + expect(state?.dominantAxisId).toBe('weekly') + expect(state?.effectiveReason).toContain('1 Week Usage') + }) + + it('flags amber covering when weekly is at 100% but credits remain (Pro account)', () => { + // This is the operator's actual state on 2026-06-09: weekly=100% + // (subscription window exhausted) but credits=246 available, so + // codex falls back to credit-billing. Previous design rendered + // red 100, which was technically true (weekly is capped) but + // operationally misleading (user can still send). + const usage: CodexUsage = { + contextWindow: { usedTokens: 207_000, limitTokens: 258_400, percent: 80, updatedAt: 1 }, + rateLimits: { + fiveHour: { usedPercent: 1, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + credits: { hasCredits: true, unlimited: false, balance: '246.0000000000' }, + limitId: 'premium' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('amber') + expect(state?.effectiveReason).toContain('credits covering') + expect(state?.operationalAxisId).toBe('context') + // Dominant should still be weekly (highest-pressure non-credits axis) + expect(state?.dominantAxisId).toBe('weekly') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.covering).toBe(true) + expect(creditsAxis?.valueText).toBe('246') + }) + + it('flags blocked when subscription + credits both exhausted', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 207_000, limitTokens: 258_400, percent: 80, updatedAt: 1 }, + rateLimits: {}, + credits: { hasCredits: false, unlimited: false, balance: '0' }, + limitId: 'premium' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + // Dominant: credits (pressure 100, critical) + expect(state?.dominantAxisId).toBe('credits') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.critical).toBe(true) + expect(creditsAxis?.valueText).toBe('0') + // Operational axis stays as context (centre number = how-much-room-for-task) + expect(state?.operationalAxisId).toBe('context') + }) + + it('flags blocked during the transition shape (both windows at 100, credits 0, windows still present)', () => { + // Cold-review finding 2026-06-09: before codex nulls out the + // exhausted windows it briefly emits both primary AND secondary + // with usedPercent=100 alongside credits.has_credits=false. + // Earlier logic only treated 'windows absent' as blocked, missing + // this transition shape - effective state landed as 'red' with + // generic 'Credits 100%' reason instead of the explicit 'Blocked'. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + expect(state?.effectiveReason).toContain('Blocked') + }) + + it('does not flag blocked when only one window is capped (the other still has room)', () => { + // 5h at cap during weekly reset window - user can still send via + // the weekly bucket. Should be red (5h at cap) not blocked. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 30, windowMinutes: 10080 } + }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('red') + }) + + it('flags blocked with the codex reached-type code when codex sets one', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 100, windowMinutes: 300 }, + weekly: { usedPercent: 100, windowMinutes: 10080 } + }, + rateLimitReachedType: 'weekly' + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('blocked') + expect(state?.effectiveReason).toContain('Weekly') + expect(state?.metadata?.[0]).toEqual({ label: 'Limit Reached', value: 'Weekly' }) + }) + + it('appends token breakdown metadata when reported', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 20_000, limitTokens: 100_000, percent: 20, updatedAt: 1 }, + rateLimits: {}, + totalTokenUsage: { + inputTokens: 1000, + cachedInputTokens: 500, + outputTokens: 250, + reasoningOutputTokens: 250, + totalTokens: 2000 + } + } + const state = toCodexBudgetState(usage) + const tokenRow = state?.metadata?.find((row) => row.label === 'Token Breakdown') + expect(tokenRow?.value).toBe('2k') + expect(tokenRow?.detail).toContain('input 1k') + }) + + it('does not flag blocked when credits.unlimited even with balance reading 0', () => { + const usage: CodexUsage = { + contextWindow: { usedTokens: 10_000, limitTokens: 100_000, percent: 10, updatedAt: 1 }, + rateLimits: {}, + credits: { hasCredits: true, unlimited: true, balance: '0' } + } + const state = toCodexBudgetState(usage) + expect(state?.effective).toBe('green') + const creditsAxis = state?.axes.find((axis) => axis.id === 'credits') + expect(creditsAxis?.valueText).toBe('Unlimited') + expect(creditsAxis?.critical).toBeFalsy() + }) + + it('handles a fresh-session payload (token_count before context-window stats arrive)', () => { + // Early in a session, codex sometimes emits rate_limits without a + // contextWindow. The adapter must still produce a usable state + // with an operational axis fallback. + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 12, windowMinutes: 300 } + } + } + const state = toCodexBudgetState(usage) + expect(state?.operationalAxisId).toBe('fiveHour') + expect(state?.effective).toBe('green') + }) + + it('chooses an operational axis fallback when context is absent', () => { + const usage: CodexUsage = { + rateLimits: { + fiveHour: { usedPercent: 30, windowMinutes: 300 }, + weekly: { usedPercent: 60, windowMinutes: 10080 } + } + } + const state = toCodexBudgetState(usage) + // No context -> pick the highest-pressure axis as operational so + // the ring centre still shows the worst non-credits pressure. + expect(state?.operationalAxisId).toBe('weekly') + }) +}) diff --git a/web/src/components/AssistantChat/codexBudgetAdapter.ts b/web/src/components/AssistantChat/codexBudgetAdapter.ts new file mode 100644 index 0000000000..a1a28a1926 --- /dev/null +++ b/web/src/components/AssistantChat/codexBudgetAdapter.ts @@ -0,0 +1,294 @@ +import type { + AgentBudgetAxis, + AgentBudgetEffectiveState, + AgentBudgetMetadataRow, + AgentBudgetState, + CodexTokenUsage, + CodexUsage, + CodexUsageRateLimit +} from '@hapi/protocol/types' + +// Codex-specific adapter that maps a CodexUsage payload into the +// flavor-agnostic AgentBudgetState the indicator consumes. All +// Codex-specific terminology (5h / weekly / credits / plan_type / etc) +// lives here, not in the renderer. + +// Codex sends balance as a precision-preserving string ('250.0000000000', +// '0', '0.0000000000'). Number() handles all of those uniformly without +// risking a literal-match miss on a new trailing-zero variant. +export function parseCreditsBalance(raw: string | undefined): number | null { + if (raw === undefined) return null + const trimmed = raw.trim() + if (trimmed.length === 0) return null + const n = Number(trimmed) + return Number.isFinite(n) ? n : null +} + +// Subscription-and-credits exhausted. Codex has two payload shapes here: +// (a) post-exhaustion: rate_limits.primary=null + secondary=null + +// credits.has_credits=false (steady state once windows have fully +// fallen back to credit billing). +// (b) transition: both rate_limits.primary and .secondary present with +// usedPercent=100 alongside credits.has_credits=false. Brief window +// before codex nulls the rate limits out, but the user IS blocked +// during it. +// Either shape, or an explicit rate_limit_reached_type, should land in +// the 'blocked' effective state so the indicator surfaces the hard cap +// with consistent messaging instead of a less-specific 'red 100%'. +export function isCodexUsageBlocked(usage: CodexUsage | null | undefined): boolean { + if (!usage) return false + if (usage.credits?.unlimited) return false + const reachedType = typeof usage.rateLimitReachedType === 'string' && usage.rateLimitReachedType.length > 0 + if (reachedType) return true + + const hasCreditsExplicitlyFalse = usage.credits?.hasCredits === false + const parsedBalance = parseCreditsBalance(usage.credits?.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 + const creditsExhausted = hasCreditsExplicitlyFalse || balanceZero + + const fiveHour = usage.rateLimits?.fiveHour + const weekly = usage.rateLimits?.weekly + const noTimeWindows = !fiveHour && !weekly + // Shape (b): both windows present but at the cap. Either-or doesn't + // trigger blocked - one window might cap while the other has room + // (e.g. 5h hit during weekly's reset period). + const bothWindowsCapped = (fiveHour?.usedPercent ?? 0) >= 100 && (weekly?.usedPercent ?? 0) >= 100 + return creditsExhausted && (noTimeWindows || bothWindowsCapped) +} + +export function formatRateLimitReachedType(value: string): string { + return value + .replace(/_/g, ' ') + .replace(/\b\w/g, (ch) => ch.toUpperCase()) +} + +export function formatCodexUsageReset(resetAt: number | undefined, locale?: string): string | null { + if (!resetAt) return null + return new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }).format(new Date(resetAt)) +} + +function clampPercent(value: number): number { + if (!Number.isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) +} + +function formatPercent(value: number): string { + const clamped = clampPercent(value) + return `${clamped >= 10 ? Math.round(clamped) : Math.round(clamped * 10) / 10}%` +} + +function formatTokens(value: number): string { + if (Math.abs(value) >= 1000) { + return `${Math.round(value / 1000)}k` + } + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) +} + +function formatRateLimit(rateLimit: CodexUsageRateLimit): string { + return formatPercent(rateLimit.usedPercent) +} + +function formatTokenBreakdown(usage: CodexTokenUsage): string { + return [ + `input ${formatTokens(usage.inputTokens)}`, + `cached ${formatTokens(usage.cachedInputTokens)}`, + `output ${formatTokens(usage.outputTokens)}`, + `reasoning ${formatTokens(usage.reasoningOutputTokens)}` + ].join(' · ') +} + +// Codex's protocol field is 'balance' with no declared unit. Credits +// are an internal billing token consumed at token-mix-dependent rates +// (per the OpenAI Codex rate card, GPT-5.5 burns 125 credits per 1M +// input tokens / 750 per 1M output, and a $5 top-up grants 125 credits +// ~ $0.04/credit). Render as a bare count; the row label 'Credits' +// carries the unit and any USD conversion belongs in chatgpt.com's +// billing UI, not the indicator. See +// https://help.openai.com/en/articles/20001106-codex-rate-card +function formatCreditsBalance(raw: string): string { + const n = parseCreditsBalance(raw) + if (n === null) return raw + if (n === 0) return '0' + const decimals = Math.abs(n) >= 1 ? 2 : 4 + return n.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: decimals + }) +} + +function formatCreditsValue(credits: NonNullable): string { + if (credits.unlimited) return 'Unlimited' + if (credits.balance !== undefined) return formatCreditsBalance(credits.balance) + if (credits.hasCredits === false) return 'Out' + if (credits.hasCredits === true) return 'Available' + return '-' +} + +// Codex-specific effective-state logic. Honours the Pro-tier billing +// rule that exhausted subscription windows fall back to credit-billing, +// so weekly=100% with credits>0 is amber (covering scenario) rather than +// red (genuinely about to fail). +function deriveEffectiveState( + usage: CodexUsage, + axes: AgentBudgetAxis[] +): { effective: AgentBudgetEffectiveState; reason: string } { + if (isCodexUsageBlocked(usage)) { + const reason = usage.rateLimitReachedType + ? `Blocked: ${formatRateLimitReachedType(usage.rateLimitReachedType)} limit reached` + : 'Blocked: subscription window and credits both exhausted' + return { effective: 'blocked', reason } + } + + const subscriptionCapped = axes.some( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + const creditsCovering = axes.some((axis) => axis.id === 'credits' && axis.covering === true) + if (subscriptionCapped && creditsCovering) { + const cappedAxis = axes.find( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + const label = cappedAxis?.label ?? 'Subscription window' + return { + effective: 'amber', + reason: `${label} at cap; credits covering overage` + } + } + + // Credits axis pressure==0 means 'available and not covering' - it + // should not push the gauge into amber/red by itself. + const pressureCandidates = axes.filter((axis) => axis.id !== 'credits' || axis.pressure > 0) + const maxPressure = pressureCandidates.length > 0 + ? Math.max(...pressureCandidates.map((axis) => axis.pressure)) + : 0 + if (maxPressure >= 95) { + const dominantAxis = pressureCandidates.find((axis) => axis.pressure === maxPressure) + return { + effective: 'red', + reason: dominantAxis ? `${dominantAxis.label} ${Math.round(maxPressure)}%` : 'Near cap' + } + } + if (maxPressure >= 60) { + const dominantAxis = pressureCandidates.find((axis) => axis.pressure === maxPressure) + return { + effective: 'amber', + reason: dominantAxis ? `${dominantAxis.label} ${Math.round(maxPressure)}%` : 'Approaching cap' + } + } + return { effective: 'green', reason: 'All budgets well below caps' } +} + +export function toCodexBudgetState(usage: CodexUsage | null | undefined): AgentBudgetState | null { + if (!usage) return null + + const axes: AgentBudgetAxis[] = [] + + if (usage.contextWindow && Number.isFinite(usage.contextWindow.percent)) { + axes.push({ + id: 'context', + label: 'Context Window', + pressure: clampPercent(usage.contextWindow.percent), + valueText: formatPercent(usage.contextWindow.percent), + detail: `${formatTokens(usage.contextWindow.usedTokens)} / ${formatTokens(usage.contextWindow.limitTokens)} tokens` + }) + } + if (usage.rateLimits?.fiveHour) { + const reset = formatCodexUsageReset(usage.rateLimits.fiveHour.resetAt) + axes.push({ + id: 'fiveHour', + label: '5h Usage', + pressure: clampPercent(usage.rateLimits.fiveHour.usedPercent), + valueText: formatRateLimit(usage.rateLimits.fiveHour), + ...(reset ? { detail: `resets ${reset}` } : {}) + }) + } + if (usage.rateLimits?.weekly) { + const reset = formatCodexUsageReset(usage.rateLimits.weekly.resetAt) + axes.push({ + id: 'weekly', + label: '1 Week Usage', + pressure: clampPercent(usage.rateLimits.weekly.usedPercent), + valueText: formatRateLimit(usage.rateLimits.weekly), + ...(reset ? { detail: `resets ${reset}` } : {}) + }) + } + if (usage.credits) { + const parsedBalance = parseCreditsBalance(usage.credits.balance) + const balanceZero = parsedBalance !== null && parsedBalance === 0 + const exhausted = !usage.credits.unlimited && (usage.credits.hasCredits === false || balanceZero) + const subscriptionExists = axes.some((axis) => axis.id === 'fiveHour' || axis.id === 'weekly') + const subscriptionCapped = axes.some( + (axis) => (axis.id === 'fiveHour' || axis.id === 'weekly') && axis.pressure >= 100 + ) + // Credits axis pressure: codex's protocol doesn't expose a + // 'capacity' to derive a true percent against, so the adapter + // picks a pragmatic mapping. When exhausted -> 100 so the row + // participates in dominant-axis selection (rendered critical). + // When credits remain, axis is 'covering' iff at least one + // subscription window is already at cap - that signals 'this + // axis is keeping you going' rather than 'this axis is a + // constraint'. + const pressure = exhausted ? 100 : 0 + const covering = !exhausted && subscriptionExists && subscriptionCapped + axes.push({ + id: 'credits', + label: 'Credits', + pressure, + valueText: formatCreditsValue(usage.credits), + ...(usage.credits.unlimited + ? { detail: 'unlimited' } + : exhausted + ? { detail: 'subscription / top-up exhausted', critical: true } + : covering + ? { detail: 'covering exhausted subscription window', covering: true } + : {}) + }) + } + + if (axes.length === 0) return null + + const metadata: AgentBudgetMetadataRow[] = [] + if (usage.rateLimitReachedType) { + metadata.push({ + label: 'Limit Reached', + value: formatRateLimitReachedType(usage.rateLimitReachedType) + }) + } + if (usage.totalTokenUsage) { + metadata.push({ + label: 'Token Breakdown', + value: formatTokens(usage.totalTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.totalTokenUsage) + }) + } else if (usage.lastTokenUsage) { + metadata.push({ + label: 'Last Turn Tokens', + value: formatTokens(usage.lastTokenUsage.totalTokens), + detail: formatTokenBreakdown(usage.lastTokenUsage) + }) + } + + const { effective, reason } = deriveEffectiveState(usage, axes) + + const operationalAxisId = axes.find((axis) => axis.id === 'context') + ? 'context' + : axes.reduce((best, axis) => (axis.pressure > best.pressure ? axis : best), axes[0]).id + + const dominantCandidates = axes.filter((axis) => !(axis.id === 'credits' && axis.pressure === 0)) + const dominant = dominantCandidates.length > 0 + ? dominantCandidates.reduce((best, axis) => (axis.pressure > best.pressure ? axis : best), dominantCandidates[0]) + : undefined + + return { + operationalAxisId, + axes, + ...(metadata.length > 0 ? { metadata } : {}), + effective, + effectiveReason: reason, + ...(dominant ? { dominantAxisId: dominant.id } : {}) + } +} diff --git a/web/src/components/AssistantChat/codexUsageDisplay.test.ts b/web/src/components/AssistantChat/codexUsageDisplay.test.ts deleted file mode 100644 index de73b469e5..0000000000 --- a/web/src/components/AssistantChat/codexUsageDisplay.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { CodexUsage } from '@hapi/protocol/types' -import { - formatCodexUsageReset, - getCodexUsageRing, - getCodexUsageRingPercent, - getCodexUsageRingTitle, - getCodexUsageRows, - isCodexUsageBlocked -} from './codexUsageDisplay' - -describe('codexUsageDisplay', () => { - it('surfaces the most-pressing axis - rate limit beats lower context fill', () => { - // Reproduces the bug screenshot from 2026-06-09: ctx=80% but - // weekly=100%. Original PR #537 preferred context and silently - // hid the hard weekly cap behind a softer context reading. Ring - // must report 100 + axis='weekly' so the popover dominant-marker - // and ring colour both reflect the real constraint. - const usage: CodexUsage = { - contextWindow: { - usedTokens: 207_000, - limitTokens: 258_400, - percent: 80, - updatedAt: 1 - }, - rateLimits: { - fiveHour: { usedPercent: 1, windowMinutes: 300 }, - weekly: { usedPercent: 100, windowMinutes: 10080 } - } - } - - const ring = getCodexUsageRing(usage) - expect(ring).toEqual({ percent: 100, axis: 'weekly' }) - expect(getCodexUsageRingPercent(usage)).toBe(100) - expect(getCodexUsageRingTitle(ring!, usage)).toContain('Weekly') - }) - - it('reports context when context dominates the rate-limit axes', () => { - const usage: CodexUsage = { - contextWindow: { - usedTokens: 80_000, - limitTokens: 100_000, - percent: 80, - updatedAt: 1 - }, - rateLimits: { - fiveHour: { usedPercent: 20, windowMinutes: 300 }, - weekly: { usedPercent: 30, windowMinutes: 10080 } - } - } - expect(getCodexUsageRing(usage)).toEqual({ percent: 80, axis: 'context' }) - }) - - it('falls back to the highest rate-limit usage when context is absent', () => { - const usage: CodexUsage = { - rateLimits: { - fiveHour: { usedPercent: 30, windowMinutes: 300 }, - weekly: { usedPercent: 60, windowMinutes: 10080 } - } - } - - expect(getCodexUsageRing(usage)).toEqual({ percent: 60, axis: 'weekly' }) - }) - - it('marks the dominant row so the popover can highlight the axis driving the ring', () => { - const usage: CodexUsage = { - contextWindow: { usedTokens: 207_000, limitTokens: 258_400, percent: 80, updatedAt: 1 }, - rateLimits: { - fiveHour: { usedPercent: 1, windowMinutes: 300 }, - weekly: { usedPercent: 100, windowMinutes: 10080 } - } - } - const rows = getCodexUsageRows(usage) - const dominant = rows.filter((r) => r.dominant) - expect(dominant.map((r) => r.label)).toEqual(['1 Week Usage']) - expect(rows.find((r) => r.label === 'Context Window')?.dominant).toBeFalsy() - }) - - it('formats detail rows and reset times', () => { - const usage: CodexUsage = { - contextWindow: { - usedTokens: 2_000, - limitTokens: 10_000, - percent: 20, - updatedAt: 1 - }, - rateLimits: { - fiveHour: { - usedPercent: 50, - windowMinutes: 300, - resetAt: Date.UTC(2026, 3, 27, 12, 0, 0) - } - }, - totalTokenUsage: { - inputTokens: 1000, - cachedInputTokens: 500, - outputTokens: 250, - reasoningOutputTokens: 250, - totalTokens: 2000 - } - } - - expect(getCodexUsageRows(usage).map((row) => row.label)).toEqual([ - 'Context Window', - '5h Usage', - 'Token Breakdown' - ]) - expect(formatCodexUsageReset(Date.UTC(2026, 3, 27, 12, 0, 0), 'en-US')).toMatch(/Apr 27/) - }) - - it('returns null when no usage is displayable', () => { - expect(getCodexUsageRingPercent({ rateLimits: {} })).toBeNull() - }) - - it('forces 100% ring + critical Credits row when subscription + credits both exhausted', () => { - // Pro account, 5h + weekly windows both depleted (primary/secondary - // become null upstream), credits topped-up but spent to 0. Without - // this branch the ring would read context-window-only (e.g. 80%) - // and silently misrepresent "blocked" as "plenty of room left". - const usage: CodexUsage = { - contextWindow: { - usedTokens: 206_000, - limitTokens: 258_400, - percent: 80, - updatedAt: 1 - }, - rateLimits: {}, - credits: { hasCredits: false, unlimited: false, balance: '0' }, - limitId: 'premium' - } - - expect(isCodexUsageBlocked(usage)).toBe(true) - expect(getCodexUsageRingPercent(usage)).toBe(100) - - const rows = getCodexUsageRows(usage) - const creditsRow = rows.find((row) => row.label === 'Credits') - expect(creditsRow?.value).toBe('0') - expect(creditsRow?.severity).toBe('critical') - expect(creditsRow?.detail).toBe('subscription / top-up exhausted') - }) - - it('formats credit balances without $ prefix and trims trailing zeros', () => { - // Codex sends balance as a precision-preserving string. Real - // payloads observed: '0' (empty), '250.0000000000' (just topped - // up $250), '0.0000000000' (also empty). All three must render - // cleanly without a $ prefix or trailing zero noise. - const cases: Array<{ raw: string; expected: string; blocked: boolean }> = [ - { raw: '0', expected: '0', blocked: true }, - { raw: '0.0000000000', expected: '0', blocked: true }, - { raw: '250.0000000000', expected: '250', blocked: false }, - { raw: '12.345', expected: '12.35', blocked: false } - ] - for (const { raw, expected, blocked } of cases) { - const usage: CodexUsage = { - rateLimits: {}, - credits: { hasCredits: !blocked, unlimited: false, balance: raw } - } - const rows = getCodexUsageRows(usage) - const creditsRow = rows.find((row) => row.label === 'Credits') - expect(creditsRow?.value, `balance="${raw}"`).toBe(expected) - expect(isCodexUsageBlocked(usage), `balance="${raw}"`).toBe(blocked) - } - }) - - it('renders Limit Reached header when codex emits rate_limit_reached_type', () => { - const usage: CodexUsage = { - rateLimits: { - fiveHour: { usedPercent: 100, windowMinutes: 300 } - }, - rateLimitReachedType: 'weekly' - } - - const rows = getCodexUsageRows(usage) - expect(rows[0]).toEqual(expect.objectContaining({ - label: 'Limit Reached', - value: 'Weekly', - severity: 'critical' - })) - expect(isCodexUsageBlocked(usage)).toBe(true) - }) - - it('does not flag blocked when credits.unlimited is true even if balance reads 0', () => { - const usage: CodexUsage = { - rateLimits: {}, - credits: { hasCredits: true, unlimited: true, balance: '0' } - } - expect(isCodexUsageBlocked(usage)).toBe(false) - const rows = getCodexUsageRows(usage) - const creditsRow = rows.find((row) => row.label === 'Credits') - expect(creditsRow?.value).toBe('Unlimited') - expect(creditsRow?.severity).toBeUndefined() - }) -}) diff --git a/web/src/components/AssistantChat/codexUsageDisplay.ts b/web/src/components/AssistantChat/codexUsageDisplay.ts deleted file mode 100644 index 7bcfab9b7e..0000000000 --- a/web/src/components/AssistantChat/codexUsageDisplay.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { CodexTokenUsage, CodexUsage, CodexUsageRateLimit } from '@hapi/protocol/types' - -export type CodexUsageRingAxis = 'blocked' | 'context' | 'fiveHour' | 'weekly' - -export type CodexUsageRing = { - percent: number - axis: CodexUsageRingAxis -} - -export type CodexUsageRow = { - label: string - value: string - detail?: string - severity?: 'critical' | 'warn' - // True when this row corresponds to the dominant axis driving the - // ring percent. Lets the popover visually link the ring meaning to - // the row that produced it (e.g. 'weekly 100%' bolded when ring=100). - dominant?: boolean -} - -// Codex sends balance as a precision-preserving string ('250.0000000000', -// '0', '0.0000000000'). Number() handles all of those uniformly without -// risking a literal-match miss on a new trailing-zero variant. -function parseCreditsBalance(raw: string | undefined): number | null { - if (raw === undefined) return null - const trimmed = raw.trim() - if (trimmed.length === 0) return null - const n = Number(trimmed) - return Number.isFinite(n) ? n : null -} - -// Subscription-and-credits exhausted: codex sends primary=null + -// secondary=null + credits.has_credits=false. The indicator should treat -// this as a "you are blocked, full red" state so users with a Pro -// subscription that ALSO topped up credits don't get a silent 80% ring. -export function isCodexUsageBlocked(usage: CodexUsage | null | undefined): boolean { - if (!usage) return false - if (usage.credits?.unlimited) return false - const hasCreditsExplicitlyFalse = usage.credits?.hasCredits === false - const parsedBalance = parseCreditsBalance(usage.credits?.balance) - const balanceZero = parsedBalance !== null && parsedBalance === 0 - const noTimeWindows = !usage.rateLimits?.fiveHour && !usage.rateLimits?.weekly - const reachedType = typeof usage.rateLimitReachedType === 'string' && usage.rateLimitReachedType.length > 0 - return reachedType || (noTimeWindows && (hasCreditsExplicitlyFalse || balanceZero)) -} - -// Codex's protocol field is 'balance' with no declared unit. Credits -// are an internal billing token consumed at token-mix-dependent rates -// (per the OpenAI Codex rate card, GPT-5.5 burns 125 credits per 1M -// input tokens / 750 per 1M output, and a $5 top-up grants 125 credits -// ~ $0.04/credit). Render as a bare count; the row label 'Credits' -// carries the unit and any USD conversion belongs in chatgpt.com's -// billing UI, not the indicator. See -// https://help.openai.com/en/articles/20001106-codex-rate-card -function formatCreditsBalance(raw: string): string { - const n = parseCreditsBalance(raw) - if (n === null) return raw - if (n === 0) return '0' - const decimals = Math.abs(n) >= 1 ? 2 : 4 - return n.toLocaleString(undefined, { - minimumFractionDigits: 0, - maximumFractionDigits: decimals - }) -} - -function formatCreditsValue(credits: NonNullable): string { - if (credits.unlimited) return 'Unlimited' - if (credits.balance !== undefined) return formatCreditsBalance(credits.balance) - if (credits.hasCredits === false) return 'Out' - if (credits.hasCredits === true) return 'Available' - return '-' -} - -function formatRateLimitReachedType(value: string): string { - return value - .replace(/_/g, ' ') - .replace(/\b\w/g, (ch) => ch.toUpperCase()) -} - -function clampPercent(value: number): number { - return Math.min(100, Math.max(0, value)) -} - -function formatPercent(value: number): string { - const clamped = clampPercent(value) - return `${clamped >= 10 ? Math.round(clamped) : Math.round(clamped * 10) / 10}%` -} - -function formatTokens(value: number): string { - if (Math.abs(value) >= 1000) { - return `${Math.round(value / 1000)}k` - } - return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) -} - -function formatRateLimit(rateLimit: CodexUsageRateLimit): string { - return formatPercent(rateLimit.usedPercent) -} - -export function formatCodexUsageReset(resetAt: number | undefined, locale?: string): string | null { - if (!resetAt) { - return null - } - return new Intl.DateTimeFormat(locale, { - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit' - }).format(new Date(resetAt)) -} - -function formatTokenBreakdown(usage: CodexTokenUsage): string { - return [ - `input ${formatTokens(usage.inputTokens)}`, - `cached ${formatTokens(usage.cachedInputTokens)}`, - `output ${formatTokens(usage.outputTokens)}`, - `reasoning ${formatTokens(usage.reasoningOutputTokens)}` - ].join(' · ') -} - -// The ring shows the most-pressing constraint across every axis the user -// can plausibly run out of in the near term (context window, 5h rolling -// subscription window, weekly rolling subscription window, or full block). -// Original PR #537 preferred contextWindow over rate limits which produced -// the confusing state where weekly=100% but the ring showed context=80%, -// silently hiding a hard cap behind a softer one. Credits are intentionally -// NOT folded into the percent because codex's protocol doesn't expose a -// 'capacity' to convert balance -> percent against; instead the blocked -// state subsumes the 'credits=0 AND windows exhausted' case at 100%. -export function getCodexUsageRing(usage: CodexUsage | null | undefined): CodexUsageRing | null { - if (!usage) return null - if (isCodexUsageBlocked(usage)) { - return { percent: 100, axis: 'blocked' } - } - const candidates: Array<{ percent: number; axis: CodexUsageRingAxis }> = [] - if (usage.contextWindow && Number.isFinite(usage.contextWindow.percent)) { - candidates.push({ percent: clampPercent(usage.contextWindow.percent), axis: 'context' }) - } - const fiveHour = usage.rateLimits?.fiveHour?.usedPercent - if (typeof fiveHour === 'number' && Number.isFinite(fiveHour)) { - candidates.push({ percent: clampPercent(fiveHour), axis: 'fiveHour' }) - } - const weekly = usage.rateLimits?.weekly?.usedPercent - if (typeof weekly === 'number' && Number.isFinite(weekly)) { - candidates.push({ percent: clampPercent(weekly), axis: 'weekly' }) - } - if (candidates.length === 0) return null - // Ties broken by insertion order (context < fiveHour < weekly) - if - // weekly and context both read 80, the more-painful-to-hit weekly - // gets surfaced. reduce instead of sort to avoid an allocation on - // every metadata patch. - return candidates.reduce((best, candidate) => - candidate.percent > best.percent ? candidate : best, - candidates[0]) -} - -// Kept for any future callers - returns just the percent without the -// dominant-axis context. ComposerButtons uses getCodexUsageRing directly. -export function getCodexUsageRingPercent(usage: CodexUsage | null | undefined): number | null { - return getCodexUsageRing(usage)?.percent ?? null -} - -const AXIS_TO_ROW_LABEL: Record = { - blocked: null, - context: 'Context Window', - fiveHour: '5h Usage', - weekly: '1 Week Usage' -} - -export function getCodexUsageRingTitle(ring: CodexUsageRing, usage: CodexUsage): string { - const pct = `${Math.round(ring.percent)}%` - switch (ring.axis) { - case 'blocked': - return usage.rateLimitReachedType - ? `Blocked: ${formatRateLimitReachedType(usage.rateLimitReachedType)} limit reached` - : 'Blocked: subscription window and credits both exhausted' - case 'context': - return `Context window ${pct} full` - case 'fiveHour': - return `5h subscription window ${pct} used` - case 'weekly': - return `Weekly subscription window ${pct} used` - } -} - -export function getCodexUsageRows(usage: CodexUsage, locale?: string): CodexUsageRow[] { - const rows: CodexUsageRow[] = [] - const ring = getCodexUsageRing(usage) - const dominantLabel = ring ? AXIS_TO_ROW_LABEL[ring.axis] : null - const markDominant = (row: CodexUsageRow): CodexUsageRow => - row.label === dominantLabel ? { ...row, dominant: true } : row - if (usage.rateLimitReachedType) { - rows.push({ - label: 'Limit Reached', - value: formatRateLimitReachedType(usage.rateLimitReachedType), - severity: 'critical' - }) - } - if (usage.contextWindow) { - rows.push(markDominant({ - label: 'Context Window', - value: formatPercent(usage.contextWindow.percent), - detail: `${formatTokens(usage.contextWindow.usedTokens)} / ${formatTokens(usage.contextWindow.limitTokens)} tokens` - })) - } - if (usage.rateLimits?.fiveHour) { - const reset = formatCodexUsageReset(usage.rateLimits.fiveHour.resetAt, locale) - rows.push(markDominant({ - label: '5h Usage', - value: formatRateLimit(usage.rateLimits.fiveHour), - detail: reset ? `resets ${reset}` : undefined - })) - } - if (usage.rateLimits?.weekly) { - const reset = formatCodexUsageReset(usage.rateLimits.weekly.resetAt, locale) - rows.push(markDominant({ - label: '1 Week Usage', - value: formatRateLimit(usage.rateLimits.weekly), - detail: reset ? `resets ${reset}` : undefined - })) - } - // Surface credit-billing state when codex reports it - either an - // unlimited flag, a hard balance, or an explicit has_credits=false. - // Subscription-and-credits-exhausted accounts (Pro + top-up both at - // zero) get a critical severity so the row is visually distinct - // from a normal "5h Usage 50%" entry. - if (usage.credits) { - const parsedBalance = parseCreditsBalance(usage.credits.balance) - const balanceZero = parsedBalance !== null && parsedBalance === 0 - const exhausted = usage.credits.hasCredits === false || balanceZero - const severity = !usage.credits.unlimited && exhausted ? 'critical' : undefined - rows.push({ - label: 'Credits', - value: formatCreditsValue(usage.credits), - detail: usage.credits.unlimited - ? 'unlimited' - : exhausted - ? 'subscription / top-up exhausted' - : undefined, - ...(severity ? { severity } : {}) - }) - } - if (usage.totalTokenUsage) { - rows.push({ - label: 'Token Breakdown', - value: formatTokens(usage.totalTokenUsage.totalTokens), - detail: formatTokenBreakdown(usage.totalTokenUsage) - }) - } - if (!usage.totalTokenUsage && usage.lastTokenUsage) { - rows.push({ - label: 'Last Turn Tokens', - value: formatTokens(usage.lastTokenUsage.totalTokens), - detail: formatTokenBreakdown(usage.lastTokenUsage) - }) - } - return rows -} From 01396d310a02d632bc3547e556784e01c7b7573c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:51:03 +0100 Subject: [PATCH 08/19] fix(codex-usage): forward importHistory through machine RPC to spawnSession The SpawnHappySession RPC handler received importHistory from the hub (hub/src/sync/rpcGateway.ts forwards it) but dropped it before the spawnSession call - so remote web resumes with 'Import history' checked executed `hapi codex resume ` without --hapi-import-history and silently skipped the history import. Fix: destructure importHistory from params and pass it to spawnSession. Chain is now complete: web -> hub RPC -> machine handler -> spawnSession -> buildCliArgs (--hapi-import-history flag). Fixes bot-review Major finding on tiann/hapi#847. Co-authored-by: Cursor --- cli/src/api/apiMachine.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 61f15fe176..cd86e7e4ac 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -249,7 +249,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName, importHistory } = params || {} if (!directory) { throw new Error('Directory is required') @@ -274,7 +274,8 @@ export class ApiMachineClient { permissionMode, token, sessionType, - worktreeName + worktreeName, + importHistory }) switch (result.type) { From 3bfff65c93f254f26509f72b1ca9d2a01928203b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:02:55 +0100 Subject: [PATCH 09/19] fix(codex-usage): cold-review fixes - duplicate history, type, popover UX Blocker: remove replayExistingEvents from local usage scanner. importCodexSessionHistory() already sends all user/agent messages to HAPI; setting replayExistingEvents:true on the local scanner caused every imported message to be sent twice (once via importHistory, once via the scanner's onEvent -> sendUserMessage/sendAgentMessage path). The scanner's role in the local launcher is live-tail only. Minor: AgentBudgetAxisId union - use (string & {}) instead of string so IDE still completes the well-known ids ('context', 'fiveHour', etc.) while accepting arbitrary flavor-specific strings at compile time. Plain string collapsed the union and lost completions. Minor: AgentBudgetIndicator popover - add pointerdown outside-click and Escape keyboard handlers so the popover closes without requiring a second button click. Nit: formatCodexUsageReset - use explicit `<= 0` guard instead of falsy `!resetAt` to make the epoch-exclusion intent clear. Co-authored-by: Cursor --- cli/src/codex/codexLocalLauncher.ts | 1 - shared/src/agentBudget.ts | 8 +++++--- .../AssistantChat/AgentBudgetIndicator.tsx | 18 ++++++++++++++++++ .../AssistantChat/codexBudgetAdapter.ts | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 0e1e59eac8..1b7b1174aa 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -85,7 +85,6 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch transcriptPath, // 中文注释:导入模式下允许 scanner 首次回放 transcript 全量内容,补齐 Codex 客户端里已有但 Hapi 还未看到的消息。 replayExistingHistory: session.replayTranscriptHistoryOnStart, - replayExistingEvents: session.importHistory, onSessionId: (sessionId) => { if (!isPrimarySessionId(sessionId)) { logger.debug(`[codex-local]: Ignoring transcript session id ${sessionId}; primary is ${primarySessionId}`); diff --git a/shared/src/agentBudget.ts b/shared/src/agentBudget.ts index a955184d7b..3b06ab5608 100644 --- a/shared/src/agentBudget.ts +++ b/shared/src/agentBudget.ts @@ -27,9 +27,11 @@ export type AgentBudgetAxisId = | 'weekly' | 'credits' // Flavor-specific axes (e.g. 'cursorPremiumRequests', 'geminiRpm') - // are permitted via plain string. Keep this loose to avoid blocking - // new flavor adapters on a shared enum churn. - | string + // are permitted. `string & {}` preserves IDE completions for the + // well-known ids while still accepting arbitrary strings at compile + // time (plain `string` would collapse the union and lose completions). + // eslint-disable-next-line @typescript-eslint/ban-types + | (string & {}) export type AgentBudgetEffectiveState = // All axes well under their caps - safe to keep working. diff --git a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx index a711d75bde..ad5d4e5ef5 100644 --- a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx +++ b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx @@ -67,6 +67,24 @@ export function AgentBudgetIndicator(props: { state: AgentBudgetState | null | u } }, [open, updatePosition]) + useLayoutEffect(() => { + if (!open) return + const handlePointerDown = (e: PointerEvent) => { + if (buttonRef.current && !buttonRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('pointerdown', handlePointerDown) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + document.removeEventListener('keydown', handleKeyDown) + } + }, [open]) + if (!props.state) return null const state = props.state diff --git a/web/src/components/AssistantChat/codexBudgetAdapter.ts b/web/src/components/AssistantChat/codexBudgetAdapter.ts index a1a28a1926..93734d7a27 100644 --- a/web/src/components/AssistantChat/codexBudgetAdapter.ts +++ b/web/src/components/AssistantChat/codexBudgetAdapter.ts @@ -63,7 +63,7 @@ export function formatRateLimitReachedType(value: string): string { } export function formatCodexUsageReset(resetAt: number | undefined, locale?: string): string | null { - if (!resetAt) return null + if (!resetAt || resetAt <= 0) return null return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', From 457814a1bad55bd682a7ca393311e7885c77548a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:06:37 +0100 Subject: [PATCH 10/19] fix(codex-usage): scope session listing to workspace roots on --workspace-root machines The listCodexSessions RPC handler returned every transcript under CODEX_HOME without checking workspace roots. On machines started with --workspace-root, this let the web Codex session picker enumerate sessions from projects outside the allowed roots - a privacy/scoping leak analogous to the existing guards on spawn and directory browsing. Fix: add an optional pathAllowed callback to registerCodexSessionHandlers and listCodexSessions. apiMachine.ts wires in an isWithinWorkspaceRoots check when normalizedWorkspaceRoots is set; sessions with a null path are also excluded when the filter is active. When no workspace roots are configured, pathAllowed is undefined and the full list is returned unchanged (same behavior as before for single-machine installs). Fixes bot-review Major finding on tiann/hapi#847 (follow-up review). Co-authored-by: Cursor --- cli/src/api/apiMachine.ts | 9 ++++++++- cli/src/modules/common/codexSessions.ts | 19 +++++++++++++++++-- .../modules/common/handlers/codexSessions.ts | 7 +++++-- .../modules/common/registerCommonHandlers.ts | 8 ++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index cd86e7e4ac..2e6feab6e7 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -92,7 +92,14 @@ export class ApiMachineClient { logger: (msg, data) => logger.debug(msg, data) }) - registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) + registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd(), { + codexSessionPathAllowed: this.normalizedWorkspaceRoots?.length + ? async (path) => { + if (!path) return false + return this.isWithinWorkspaceRoots(await this.resolveForWorkspaceCheck(path)) + } + : undefined + }) this.rpcHandlerManager.registerHandler(RPC_METHODS.PathExists, async (params) => { const rawPaths = Array.isArray(params?.paths) ? params.paths : [] diff --git a/cli/src/modules/common/codexSessions.ts b/cli/src/modules/common/codexSessions.ts index 11edd07022..491322904f 100644 --- a/cli/src/modules/common/codexSessions.ts +++ b/cli/src/modules/common/codexSessions.ts @@ -392,7 +392,10 @@ export async function findCodexSessionTitle(sessionId: string): Promise { +export async function listCodexSessions( + request: ListCodexSessionsRequest = {}, + pathAllowed?: (path: string | null) => boolean | Promise +): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> { const includeOld = request.includeOld === true; const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0 ? Number(request.olderThanDays) @@ -436,7 +439,19 @@ export async function listCodexSessions(request: ListCodexSessionsRequest = {}): isOld: entry.updatedAt < cutoff })); - const filtered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld); + const ageFiltered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld); + + // Apply workspace-root scoping when the machine runs with --workspace-root. + // Sessions whose `path` is outside the allowed roots are dropped so the web + // picker never exposes projects from other workspaces. Sessions with a null + // path are also excluded when a filter is active. + const filtered = pathAllowed + ? (await Promise.all(ageFiltered.map(async (entry) => ({ + entry, + allowed: await pathAllowed(entry.path ?? null) + })))).filter(({ allowed }) => allowed).map(({ entry }) => entry) + : ageFiltered; + const sliced = filtered.slice(offset, offset + limit); const nextOffset = offset + sliced.length; diff --git a/cli/src/modules/common/handlers/codexSessions.ts b/cli/src/modules/common/handlers/codexSessions.ts index e3e256fcc8..4bdb10314a 100644 --- a/cli/src/modules/common/handlers/codexSessions.ts +++ b/cli/src/modules/common/handlers/codexSessions.ts @@ -7,12 +7,15 @@ import { } from '../codexSessions'; import { getErrorMessage, rpcError } from '../rpcResponses'; -export function registerCodexSessionHandlers(rpcHandlerManager: RpcHandlerManager): void { +export function registerCodexSessionHandlers( + rpcHandlerManager: RpcHandlerManager, + pathAllowed?: (path: string | null) => boolean | Promise +): void { rpcHandlerManager.registerHandler('listCodexSessions', async (data) => { logger.debug('List Codex sessions request'); try { - const result = await listCodexSessions(data ?? {}); + const result = await listCodexSessions(data ?? {}, pathAllowed); return { success: true, sessions: result.sessions, diff --git a/cli/src/modules/common/registerCommonHandlers.ts b/cli/src/modules/common/registerCommonHandlers.ts index 575edfaae0..c7d6103a9b 100644 --- a/cli/src/modules/common/registerCommonHandlers.ts +++ b/cli/src/modules/common/registerCommonHandlers.ts @@ -13,10 +13,14 @@ import { registerSlashCommandHandlers } from './handlers/slashCommands' import { registerSkillsHandlers } from './handlers/skills' import { registerUploadHandlers } from './handlers/uploads' -export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void { +export function registerCommonHandlers( + rpcHandlerManager: RpcHandlerManager, + workingDirectory: string, + options: { codexSessionPathAllowed?: (path: string | null) => boolean | Promise } = {} +): void { registerBashHandlers(rpcHandlerManager, workingDirectory) registerCodexModelHandlers(rpcHandlerManager) - registerCodexSessionHandlers(rpcHandlerManager) + registerCodexSessionHandlers(rpcHandlerManager, options.codexSessionPathAllowed) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) From 5e41eeee1da4a46726874a99de4809a7a8aa2a88 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:53:25 +0100 Subject: [PATCH 11/19] fix(codex-usage): use selected session's path as spawn directory on resume When the user picks a Codex session from the history picker the spawn call still sent the form's directory input as `directory`, so resuming a session from /repo-a while the input showed /repo-b launched Codex in the wrong workspace with the wrong files. Fix: resolve `selectedCodexSession.path` from the sessions list and use that as `spawnDirectory` when a Codex session is selected. Falls back to `trimmedDirectory` for plain new sessions. Also: relax `canCreate` guard to allow spawn when a Codex session is selected even if the directory input is empty (the path comes from the session, not the form). Fixes bot-review Major finding on tiann/hapi#847. Co-authored-by: Cursor --- web/src/components/NewSession/index.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 55ee0f301a..67ef79e59a 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -542,12 +542,22 @@ export function NewSession(props: { }, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect]) async function handleCreate() { - if (!machineId || !trimmedDirectory) return + if (!machineId) return + + // When resuming a Codex session, use that session's recorded workspace + // path as the directory. The form's directory input is irrelevant - the + // transcript belongs to a specific project and Codex must run there. + const selectedCodexSession = agent === 'codex' && selectedCodexSessionId + ? (codexSessionsState.sessions.find((s) => s.id === selectedCodexSessionId) ?? null) + : null + const spawnDirectory = selectedCodexSession?.path ?? trimmedDirectory + + if (!spawnDirectory) return setError(null) try { - const existsResult = await checkPathsExists([trimmedDirectory]) - const directoryExists = existsResult[trimmedDirectory] + const existsResult = await checkPathsExists([spawnDirectory]) + const directoryExists = existsResult[spawnDirectory] if (sessionType === 'worktree' && directoryExists === false) { haptic.notification('error') @@ -581,7 +591,7 @@ export function NewSession(props: { : undefined const result = await spawnSession({ machineId, - directory: trimmedDirectory, + directory: spawnDirectory, agent, model: resolvedModel, effort: resolvedEffort, @@ -597,7 +607,7 @@ export function NewSession(props: { haptic.notification('success') clearNewSessionFormDraft() setLastUsedMachineId(machineId) - addRecentPath(machineId, trimmedDirectory) + addRecentPath(machineId, spawnDirectory) props.onSuccess(result.sessionId) return } @@ -610,7 +620,8 @@ export function NewSession(props: { } } - const canCreate = Boolean(machineId && trimmedDirectory && !isFormDisabled && !missingWorktreeDirectory) + const hasCodexSessionSelected = agent === 'codex' && Boolean(selectedCodexSessionId) + const canCreate = Boolean(machineId && (trimmedDirectory || hasCodexSessionSelected) && !isFormDisabled && !missingWorktreeDirectory) return (
From 79a17b7949a472ed51aabde090d33d3928670718 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:13:28 +0100 Subject: [PATCH 12/19] fix(web): propagate metadata fields in patchSessionSummary When a session-updated SSE event carries a metadata patch (e.g. a Codex history import setting codexSessionId or title), patchSessionSummary now maps it through toSummaryMetadata so the sessions list cache reflects name/path/summary/agentSessionId changes immediately. Previously, patchSessionSummary returned `true` (patch applied) while silently ignoring the metadata payload, suppressing the queueSessionListInvalidation() fallback and leaving stale titles/paths in the list until the next full refetch. Co-authored-by: Cursor --- web/src/hooks/useSSE.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index b25a91c8c1..b25437f2e1 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -8,8 +8,9 @@ import type { Session, SessionPatch, SessionResponse, - SessionsResponse, SessionSummary, + SessionSummaryMetadata, + SessionsResponse, SyncEvent } from '@/types/api' import { queryKeys } from '@/lib/query-keys' @@ -317,6 +318,24 @@ export function useSSE(options: { }) } + const toSummaryMetadata = (metadata: NonNullable | null): SessionSummaryMetadata | null => + metadata ? { + name: metadata.name, + path: metadata.path, + machineId: metadata.machineId ?? undefined, + summary: metadata.summary ? { text: metadata.summary.text } : undefined, + flavor: metadata.flavor ?? null, + worktree: metadata.worktree, + agentSessionId: metadata.codexSessionId + ?? metadata.claudeSessionId + ?? metadata.geminiSessionId + ?? metadata.opencodeSessionId + ?? metadata.cursorSessionId + ?? metadata.kimiSessionId + ?? undefined, + lifecycleState: metadata.lifecycleState + } : null + const patchSessionSummary = (sessionId: string, patch: SessionPatch): boolean => { let patched = false queryClient.setQueryData(queryKeys.sessions, (previous) => { @@ -337,6 +356,9 @@ export function useSSE(options: { const nextSummary: SessionSummary = { ...current, + metadata: Object.prototype.hasOwnProperty.call(patch, 'metadata') + ? toSummaryMetadata(patch.metadata ?? null) + : current.metadata, active: patch.active ?? current.active, thinking: patch.thinking ?? current.thinking, activeAt: patch.activeAt ?? current.activeAt, From fd7547ca433758fa31d3eadc5f5d9545d5e9acda Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 11 Jun 2026 01:14:37 +0100 Subject: [PATCH 13/19] fix(codex): prevent double-replay and stale-selection regressions Two bot-flagged Majors: 1. replayTranscriptHistoryOnStart: add !opts.importHistory guard so that when history is imported via importCodexSessionHistory() the transcript is not replayed a second time if the session is later handed off to local mode (codexLocalLauncher). 2. Stale Codex session selection: add useEffect in NewSession that clears selectedCodexSessionId whenever the selected id is no longer present in the filtered sessions list (e.g. after toggling showOldCodexSessions off), preventing handleCreate() from silently resuming a hidden thread. Co-authored-by: Cursor --- cli/src/codex/runCodex.ts | 2 +- web/src/components/NewSession/index.tsx | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 6d011c3762..de6b11ce5b 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -77,7 +77,7 @@ export async function runCodex(opts: { const sessionWrapperRef: { current: CodexSession | null } = { current: null }; // 中文注释:当用户直接把现成的 Codex thread 导入到一个全新的 Hapi 会话时, // 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。 - const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId); + const replayTranscriptHistoryOnStart = Boolean(opts.resumeSessionId && !opts.existingSessionId && !opts.importHistory); let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 67ef79e59a..0596c3e170 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -196,6 +196,14 @@ export function NewSession(props: { enabled: agent === 'codex' && Boolean(machineId) }) + useEffect(() => { + if (!selectedCodexSessionId) return + if (codexSessionsState.isLoading) return + if (!codexSessionsState.sessions.some((s) => s.id === selectedCodexSessionId)) { + setSelectedCodexSessionId('') + } + }, [selectedCodexSessionId, codexSessionsState.isLoading, codexSessionsState.sessions]) + const [opencodeSelectedModel, setOpencodeSelectedModel] = useState(null) const runnerSpawnError = useMemo( () => formatRunnerSpawnError(selectedMachine), From be0d99198f77e14526a430eeca06bbf5a15f951f Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:37:08 +0100 Subject: [PATCH 14/19] fix(web): paginate Codex session picker to fetch all pages useCodexSessions was discarding nextCursor after the first request, capping the resume/import selector at one page (100 sessions). Replace the single fetch with a do-while pagination loop so all matching threads are surfaced regardless of how many the CLI scanner has indexed. Co-authored-by: Cursor --- web/src/hooks/queries/useCodexSessions.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/web/src/hooks/queries/useCodexSessions.ts b/web/src/hooks/queries/useCodexSessions.ts index 8be3f5a6f4..6c7dbcb1f7 100644 --- a/web/src/hooks/queries/useCodexSessions.ts +++ b/web/src/hooks/queries/useCodexSessions.ts @@ -23,11 +23,20 @@ export function useCodexSessions(args: { if (!api || !machineId) { throw new Error('Codex sessions target unavailable') } - return await api.getMachineCodexSessions(machineId, { - includeOld, - olderThanDays: 180, - limit: 200 - }) + const sessions: CodexSessionSummary[] = [] + let cursor: string | undefined + do { + const page = await api.getMachineCodexSessions(machineId, { + includeOld, + olderThanDays: 180, + limit: 100, + cursor + }) + if (page.success === false) return page + sessions.push(...(page.sessions ?? [])) + cursor = page.nextCursor ?? undefined + } while (cursor) + return { success: true, sessions, nextCursor: null } }, enabled, staleTime: 30_000, From 179f7c4b5e46831664b8f923b01472f52ed42546 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:45:16 +0100 Subject: [PATCH 15/19] feat(claude): wire claude rate-limit + context window into agent budget indicator Phase B of umbrella tiann/hapi#846 (cross-flavor agent budget gauges). Builds on the Phase A Codex adapter (PR #847) by adding a Claude adapter that converts the SDK telemetry stream into the same AgentBudgetState shape, so the existing AgentBudgetIndicator renderer needs no flavor changes - the only flavor switch is in ComposerButtons. Telemetry sources (all from @anthropic-ai/claude-code SDK message stream observed in cli/src/claude/claudeRemoteLauncher.ts): - rate_limit_event: status / resetsAt / utilization / rateLimitType for session_5h, weekly_max, and any future opaque types - assistant.message.usage: per-turn input + cache_read + cache_creation for the context-window gauge - result.modelUsage[model]: contextWindow + maxOutputTokens (so we never need a hard-coded model->window table) and per-turn token totals - result.total_cost_usd: cumulative session cost New shared shape (shared/src/schemas.ts): - ClaudeRateLimitSchema (record over opaque rateLimitType for forward compat - no enum churn on new variants) - ClaudeUsageSchema with contextWindow / rateLimits / modelUsage / totalCostUSD / resolvedModel - Metadata.claudeUsage: optional ClaudeUsageSchema Wire path: - claudeRemoteLauncher.onMessage: extractClaudeUsageInput short-circuits on non-telemetry messages (text deltas, tool calls) so we don't take the metadata lock + socket roundtrip per SDK message - normalizeClaudeUsage merges patches into session.metadata.claudeUsage - session.client.updateMetadata broadcasts via the existing versioned metadata path - no new SSE events, no schema bump - toClaudeBudgetState (web) maps into AgentBudgetState; ComposerButtons routes by agentFlavor === 'claude' Effective state rules (Claude-specific - simpler than Codex): - blocked: any rate limit status === 'rejected' - red: any axis pressure >= 90 - amber: any axis pressure >= 60 - green: otherwise (No credit-cover fallback - Claude bills by subscription only.) Tests: - cli: 19 tests for extractClaudeUsageInput + normalizeClaudeUsage + ingestClaudeSDKMessage - web: 15 tests for toClaudeBudgetState covering thresholds, dominance, rejected state, unknown rate-limit types, metadata rows - shared: 5 tests for ClaudeUsageSchema + Metadata round-trip Includes a duplicate of shared/src/agentBudget.ts that also lives in PR #847; the duplicate is identical content so a merge after #847 lands is a trivial accept-either resolution. Standalone, this PR can land before or after #847 with the same outcome. Co-authored-with-context: original codex indicator authored by @dsus4wang in tiann/hapi#537 (carried forward as PR #847). Co-authored-by: Cursor --- cli/src/claude/claudeRemoteLauncher.ts | 21 ++ cli/src/claude/utils/claudeUsage.test.ts | 290 ++++++++++++++++++ cli/src/claude/utils/claudeUsage.ts | 245 +++++++++++++++ shared/src/claudeUsageSchema.test.ts | 95 ++++++ shared/src/schemas.ts | 63 +++- shared/src/types.ts | 20 +- .../AssistantChat/ComposerButtons.tsx | 26 +- .../AssistantChat/HappyComposer.tsx | 7 + .../AssistantChat/claudeBudgetAdapter.test.ts | 161 ++++++++++ .../AssistantChat/claudeBudgetAdapter.ts | 242 +++++++++++++++ web/src/components/SessionChat.tsx | 1 + 11 files changed, 1160 insertions(+), 11 deletions(-) create mode 100644 cli/src/claude/utils/claudeUsage.test.ts create mode 100644 cli/src/claude/utils/claudeUsage.ts create mode 100644 shared/src/claudeUsageSchema.test.ts create mode 100644 web/src/components/AssistantChat/claudeBudgetAdapter.test.ts create mode 100644 web/src/components/AssistantChat/claudeBudgetAdapter.ts diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f4..2a22089f15 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -8,6 +8,7 @@ import { SDKAssistantMessage, SDKMessage, SDKUserMessage } from "./sdk"; import { formatClaudeMessageForInk } from "@/ui/messageFormatterInk"; import { logger } from "@/ui/logger"; import { SDKToLogConverter } from "./utils/sdkToLogConverter"; +import { extractClaudeUsageInput, normalizeClaudeUsage } from "./utils/claudeUsage"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; @@ -117,9 +118,29 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { let planModeToolCalls = new Set(); let ongoingToolCalls = new Map(); + // Stream Claude SDK telemetry into session.metadata.claudeUsage so the + // web agent budget indicator can render rate-limit + context-window + // pressure for this session. Short-circuit on messages without usage + // telemetry (e.g. text deltas, tool calls, control responses) so we + // don't take the metadata lock + socket roundtrip per SDK message. + // Failures are swallowed so the chat path is never blocked. + const updateClaudeUsageMetadata = (message: SDKMessage): void => { + const input = extractClaudeUsageInput(message); + if (!input) return; + try { + session.client.updateMetadata((currentMetadata) => ({ + ...currentMetadata, + claudeUsage: normalizeClaudeUsage(currentMetadata?.claudeUsage, input) + })); + } catch (error) { + logger.debug('[remote]: failed to update claudeUsage metadata', error); + } + }; + function onMessage(message: SDKMessage) { formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); + updateClaudeUsageMetadata(message); if (message.type === 'assistant') { let umessage = message as SDKAssistantMessage; diff --git a/cli/src/claude/utils/claudeUsage.test.ts b/cli/src/claude/utils/claudeUsage.test.ts new file mode 100644 index 0000000000..f8e4bad941 --- /dev/null +++ b/cli/src/claude/utils/claudeUsage.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest' + +import { + extractClaudeUsageInput, + ingestClaudeSDKMessage, + normalizeClaudeUsage +} from './claudeUsage' + +describe('extractClaudeUsageInput', () => { + it('returns null for chat-only messages', () => { + expect(extractClaudeUsageInput({ type: 'user', message: { role: 'user', content: 'hi' } } as any)).toBeNull() + expect(extractClaudeUsageInput({ type: 'control_response', response: {} } as any)).toBeNull() + }) + + it('extracts a rate_limit_event with rejected status', () => { + const input = extractClaudeUsageInput({ + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + rateLimitType: 'session_5h', + resetsAt: 1780000000000, + utilization: 1 + } + } as any) + expect(input?.rateLimitEvent).toEqual({ + status: 'rejected', + rateLimitType: 'session_5h', + resetsAt: 1780000000000, + utilization: 1 + }) + }) + + it('extracts a rate_limit_event with allowed_warning status', () => { + const input = extractClaudeUsageInput({ + type: 'rate_limit_event', + rate_limit_info: { + status: 'allowed_warning', + rateLimitType: 'weekly_max', + resetsAt: 1780000000000, + utilization: 0.85 + } + } as any) + expect(input?.rateLimitEvent?.status).toBe('allowed_warning') + expect(input?.rateLimitEvent?.utilization).toBe(0.85) + }) + + it('drops malformed rate_limit_event payloads', () => { + expect(extractClaudeUsageInput({ type: 'rate_limit_event' } as any)).toBeNull() + expect(extractClaudeUsageInput({ type: 'rate_limit_event', rate_limit_info: null } as any)).toBeNull() + expect(extractClaudeUsageInput({ + type: 'rate_limit_event', + rate_limit_info: { status: 'rejected' } + } as any)).toBeNull() + expect(extractClaudeUsageInput({ + type: 'rate_limit_event', + rate_limit_info: { status: 'unknown', rateLimitType: 'x' } + } as any)).toBeNull() + }) + + it('extracts resolvedModel from system init message', () => { + const input = extractClaudeUsageInput({ + type: 'system', + subtype: 'init', + model: 'claude-sonnet-4-5' + } as any) + expect(input?.resolvedModel).toBe('claude-sonnet-4-5') + }) + + it('ignores non-init system messages', () => { + expect(extractClaudeUsageInput({ + type: 'system', + subtype: 'compact_summary' + } as any)).toBeNull() + }) + + it('extracts assistant usage including cache tokens and model id', () => { + const input = extractClaudeUsageInput({ + type: 'assistant', + message: { + role: 'assistant', + content: [], + model: 'claude-opus-4-5', + usage: { + input_tokens: 1234, + output_tokens: 567, + cache_read_input_tokens: 89000, + cache_creation_input_tokens: 2000 + } + } + } as any) + expect(input?.assistantUsage).toEqual({ + inputTokens: 1234, + outputTokens: 567, + cacheReadInputTokens: 89000, + cacheCreationInputTokens: 2000, + model: 'claude-opus-4-5' + }) + }) + + it('extracts modelUsage and totalCostUSD from result message', () => { + const input = extractClaudeUsageInput({ + type: 'result', + subtype: 'success', + num_turns: 3, + total_cost_usd: 0.12, + duration_ms: 1000, + duration_api_ms: 800, + is_error: false, + session_id: 'sess-1', + modelUsage: { + 'claude-sonnet-4-5': { + inputTokens: 100, + outputTokens: 200, + contextWindow: 200000, + maxOutputTokens: 8192, + costUSD: 0.12 + } + } + } as any) + expect(input?.totalCostUSD).toBe(0.12) + expect(input?.modelUsage?.['claude-sonnet-4-5']?.contextWindow).toBe(200000) + }) + + it('returns null for result message without usage telemetry', () => { + expect(extractClaudeUsageInput({ + type: 'result', + subtype: 'error_during_execution', + num_turns: 0, + duration_ms: 0, + duration_api_ms: 0, + is_error: true, + session_id: 'x' + } as any)).toBeNull() + }) +}) + +describe('normalizeClaudeUsage', () => { + it('starts from empty snapshot when prev is undefined', () => { + const next = normalizeClaudeUsage(undefined, { + resolvedModel: 'claude-sonnet-4-5', + occurredAt: 1 + }) + expect(next.resolvedModel).toBe('claude-sonnet-4-5') + expect(next.rateLimits).toEqual({}) + expect(next.contextWindow).toBeUndefined() + }) + + it('merges rate-limit events keyed by rateLimitType', () => { + const a = normalizeClaudeUsage(undefined, { + rateLimitEvent: { + status: 'allowed_warning', + rateLimitType: 'session_5h', + resetsAt: 100, + utilization: 0.5 + }, + occurredAt: 10 + }) + const b = normalizeClaudeUsage(a, { + rateLimitEvent: { + status: 'rejected', + rateLimitType: 'weekly_max', + resetsAt: 200 + }, + occurredAt: 20 + }) + expect(Object.keys(b.rateLimits ?? {})).toEqual(['session_5h', 'weekly_max']) + expect(b.rateLimits?.['weekly_max']?.utilization).toBe(1) + expect(b.rateLimits?.['session_5h']?.utilization).toBe(0.5) + }) + + it('overwrites a stale rate-limit when a fresh one arrives for same type', () => { + const a = normalizeClaudeUsage(undefined, { + rateLimitEvent: { + status: 'allowed_warning', + rateLimitType: 'session_5h', + resetsAt: 100, + utilization: 0.5 + }, + occurredAt: 10 + }) + const b = normalizeClaudeUsage(a, { + rateLimitEvent: { + status: 'rejected', + rateLimitType: 'session_5h', + resetsAt: 200, + utilization: 1 + }, + occurredAt: 20 + }) + expect(Object.keys(b.rateLimits ?? {})).toEqual(['session_5h']) + expect(b.rateLimits?.['session_5h']).toEqual({ + status: 'rejected', + rateLimitType: 'session_5h', + resetsAt: 200, + utilization: 1, + updatedAt: 20 + }) + }) + + it('does not pre-compute context window without a known limit', () => { + const next = normalizeClaudeUsage(undefined, { + assistantUsage: { + inputTokens: 1000, + cacheReadInputTokens: 9000 + }, + occurredAt: 1 + }) + expect(next.contextWindow).toBeUndefined() + }) + + it('computes context window once modelUsage gives a limit', () => { + const withResult = normalizeClaudeUsage(undefined, { + resolvedModel: 'claude-sonnet-4-5', + modelUsage: { 'claude-sonnet-4-5': { contextWindow: 200000 } }, + occurredAt: 1 + }) + const withAssistant = normalizeClaudeUsage(withResult, { + assistantUsage: { + inputTokens: 5000, + outputTokens: 200, + cacheReadInputTokens: 45000, + cacheCreationInputTokens: 0 + }, + occurredAt: 2 + }) + expect(withAssistant.contextWindow?.usedTokens).toBe(50000) + expect(withAssistant.contextWindow?.limitTokens).toBe(200000) + expect(withAssistant.contextWindow?.percent).toBeCloseTo(25, 1) + }) + + it('falls back to any model contextWindow when assistant message has no model id', () => { + const withResult = normalizeClaudeUsage(undefined, { + modelUsage: { 'claude-opus-4-5': { contextWindow: 200000 } }, + occurredAt: 1 + }) + const withAssistant = normalizeClaudeUsage(withResult, { + assistantUsage: { inputTokens: 1000 }, + occurredAt: 2 + }) + expect(withAssistant.contextWindow?.limitTokens).toBe(200000) + }) + + it('merges modelUsage entries shallowly', () => { + const a = normalizeClaudeUsage(undefined, { + modelUsage: { 'claude-sonnet-4-5': { inputTokens: 100, costUSD: 0.01 } }, + occurredAt: 1 + }) + const b = normalizeClaudeUsage(a, { + modelUsage: { 'claude-sonnet-4-5': { outputTokens: 200, costUSD: 0.05 } }, + occurredAt: 2 + }) + expect(b.modelUsage?.['claude-sonnet-4-5']).toEqual({ + inputTokens: 100, + outputTokens: 200, + costUSD: 0.05 + }) + }) + + it('clamps context-window percent to 0..100 bounds', () => { + const withResult = normalizeClaudeUsage(undefined, { + modelUsage: { 'm': { contextWindow: 100 } }, + occurredAt: 1 + }) + const overflow = normalizeClaudeUsage(withResult, { + assistantUsage: { inputTokens: 999 }, + occurredAt: 2 + }) + expect(overflow.contextWindow?.percent).toBe(100) + }) +}) + +describe('ingestClaudeSDKMessage', () => { + it('passes through prev when message has no telemetry', () => { + const prev = { rateLimits: {} } + const next = ingestClaudeSDKMessage(prev, { type: 'user', message: { role: 'user', content: 'hi' } } as any) + expect(next).toBe(prev) + }) + + it('updates rate limits from a rate_limit_event message', () => { + const next = ingestClaudeSDKMessage(undefined, { + type: 'rate_limit_event', + rate_limit_info: { + status: 'rejected', + rateLimitType: 'session_5h', + resetsAt: 100 + } + } as any) + expect(next?.rateLimits?.['session_5h']?.status).toBe('rejected') + }) +}) diff --git a/cli/src/claude/utils/claudeUsage.ts b/cli/src/claude/utils/claudeUsage.ts new file mode 100644 index 0000000000..e61c26d81e --- /dev/null +++ b/cli/src/claude/utils/claudeUsage.ts @@ -0,0 +1,245 @@ +/** + * Normalize Claude SDK telemetry into the structured `ClaudeUsage` shape that + * lives on `session.metadata.claudeUsage`. Consumed by the web budget indicator + * via the `toClaudeBudgetState` adapter. + * + * Data sources (all observed in `@anthropic-ai/claude-code` SDK message stream): + * - `rate_limit_event` SDK message: subscription rate-limit telemetry + * (status, resetsAt, utilization, rateLimitType). + * - `assistant` message `.message.usage`: per-turn token usage + * (input/output/cache_read/cache_creation). + * - `result` message `.modelUsage[model]`: cumulative per-model usage + * including `contextWindow` and `maxOutputTokens` reported by the SDK + * so we don't need a hard-coded model-to-context table. + * - `result` message `.total_cost_usd`: cumulative cost. + * - `system` init message `.model`: resolved model id for that session. + * + * Local (file-backed) Claude sessions also flow through the converter that + * flattens rate-limit events into pipe-delimited assistant text; for those we + * could parse the text back out, but v1 ships the SDK-side path only (the + * remote launcher) which covers all hub-attached sessions. + */ + +import type { ClaudeUsage, ClaudeRateLimit } from '@hapi/protocol/schemas' +import type { + SDKMessage, + SDKAssistantMessage, + SDKResultMessage, + SDKSystemMessage +} from '@/claude/sdk' + +export type ClaudeUsageInput = { + rateLimitEvent?: { + status: 'allowed' | 'allowed_warning' | 'rejected' + rateLimitType: string + resetsAt?: number + utilization?: number + } + assistantUsage?: { + inputTokens?: number + outputTokens?: number + cacheReadInputTokens?: number + cacheCreationInputTokens?: number + model?: string + } + modelUsage?: Record + totalCostUSD?: number + resolvedModel?: string + occurredAt: number +} + +const RATE_LIMIT_STATUSES = new Set(['allowed', 'allowed_warning', 'rejected']) + +/** + * Turn an SDK message into a `ClaudeUsageInput` if it carries usage telemetry. + * Returns null for messages that don't move the needle (text deltas, tool + * calls without usage, etc). + */ +export function extractClaudeUsageInput(message: SDKMessage): ClaudeUsageInput | null { + const now = Date.now() + + if (message.type === 'rate_limit_event') { + const info = (message as any).rate_limit_info + if (typeof info !== 'object' || info === null) return null + const status = info.status + const rateLimitType = info.rateLimitType + if (typeof rateLimitType !== 'string' || rateLimitType.length === 0) return null + if (typeof status !== 'string' || !RATE_LIMIT_STATUSES.has(status)) return null + return { + rateLimitEvent: { + status: status as 'allowed' | 'allowed_warning' | 'rejected', + rateLimitType, + resetsAt: typeof info.resetsAt === 'number' ? info.resetsAt : undefined, + utilization: typeof info.utilization === 'number' ? info.utilization : undefined + }, + occurredAt: now + } + } + + if (message.type === 'system') { + const sys = message as SDKSystemMessage + if (sys.subtype === 'init' && typeof sys.model === 'string' && sys.model.length > 0) { + return { resolvedModel: sys.model, occurredAt: now } + } + return null + } + + if (message.type === 'assistant') { + const assist = message as SDKAssistantMessage + const raw = (assist.message as any)?.usage + if (typeof raw !== 'object' || raw === null) return null + const model = typeof (assist.message as any)?.model === 'string' + ? (assist.message as any).model + : undefined + return { + assistantUsage: { + inputTokens: typeof raw.input_tokens === 'number' ? raw.input_tokens : undefined, + outputTokens: typeof raw.output_tokens === 'number' ? raw.output_tokens : undefined, + cacheReadInputTokens: typeof raw.cache_read_input_tokens === 'number' + ? raw.cache_read_input_tokens + : undefined, + cacheCreationInputTokens: typeof raw.cache_creation_input_tokens === 'number' + ? raw.cache_creation_input_tokens + : undefined, + model + }, + occurredAt: now + } + } + + if (message.type === 'result') { + const result = message as SDKResultMessage + const input: ClaudeUsageInput = { occurredAt: now } + if (result.modelUsage && typeof result.modelUsage === 'object') { + input.modelUsage = result.modelUsage + } + if (typeof result.total_cost_usd === 'number') { + input.totalCostUSD = result.total_cost_usd + } + return input.modelUsage || typeof input.totalCostUSD === 'number' ? input : null + } + + return null +} + +/** + * Compute the effective context-window limit for a model id from the + * cumulative `modelUsage` map (the SDK reports it on `result` messages). + */ +function resolveContextWindowLimit( + modelUsage: ClaudeUsage['modelUsage'], + model: string | undefined +): number | undefined { + if (!modelUsage) return undefined + if (model) { + const direct = modelUsage[model]?.contextWindow + if (typeof direct === 'number' && direct > 0) return direct + } + // Fallback: first model with a reported contextWindow. The SDK only + // populates this on `result` messages, so on the very first assistant + // message we may not have a per-model entry yet for the active model; + // grabbing any reported window is better than dropping the gauge entirely. + for (const entry of Object.values(modelUsage)) { + if (typeof entry.contextWindow === 'number' && entry.contextWindow > 0) { + return entry.contextWindow + } + } + return undefined +} + +/** + * Merge a `ClaudeUsageInput` into the existing `ClaudeUsage` snapshot. + * Pure function - returns a new object, never mutates the previous one. + */ +export function normalizeClaudeUsage( + prev: ClaudeUsage | undefined, + input: ClaudeUsageInput +): ClaudeUsage { + const next: ClaudeUsage = { + contextWindow: prev?.contextWindow, + rateLimits: { ...(prev?.rateLimits ?? {}) }, + modelUsage: { ...(prev?.modelUsage ?? {}) }, + totalCostUSD: prev?.totalCostUSD, + resolvedModel: prev?.resolvedModel + } + + if (input.resolvedModel) { + next.resolvedModel = input.resolvedModel + } + + if (input.rateLimitEvent) { + const e = input.rateLimitEvent + const utilization = typeof e.utilization === 'number' + ? e.utilization + : e.status === 'rejected' ? 1 : 0 + const merged: ClaudeRateLimit = { + status: e.status, + rateLimitType: e.rateLimitType, + utilization, + updatedAt: input.occurredAt + } + if (typeof e.resetsAt === 'number') { + merged.resetsAt = e.resetsAt + } + next.rateLimits = { ...next.rateLimits, [e.rateLimitType]: merged } + } + + if (input.modelUsage) { + const merged: ClaudeUsage['modelUsage'] = { ...next.modelUsage } + for (const [model, usage] of Object.entries(input.modelUsage)) { + merged[model] = { ...(merged[model] ?? {}), ...usage } + } + next.modelUsage = merged + } + + if (typeof input.totalCostUSD === 'number') { + next.totalCostUSD = input.totalCostUSD + } + + if (input.assistantUsage) { + const u = input.assistantUsage + const model = u.model ?? next.resolvedModel + const limit = resolveContextWindowLimit(next.modelUsage, model) + // Context "used" is input + cached reads + cache creation. Output tokens + // grow the assistant turn but don't count against the input window; + // they're tracked separately as cost / output volume. + const usedTokens = (u.inputTokens ?? 0) + + (u.cacheReadInputTokens ?? 0) + + (u.cacheCreationInputTokens ?? 0) + if (limit && limit > 0) { + next.contextWindow = { + usedTokens, + limitTokens: limit, + percent: Math.max(0, Math.min(100, (usedTokens / limit) * 100)), + updatedAt: input.occurredAt + } + } else if (next.contextWindow) { + // Keep the previous context window; we'll overwrite once we see the + // first `result` message that carries the per-model limit. + } + } + + return next +} + +/** + * Convenience: ingest a raw SDK message and produce the next usage snapshot, + * or return `prev` unchanged if the message carries no usage telemetry. + */ +export function ingestClaudeSDKMessage( + prev: ClaudeUsage | undefined, + message: SDKMessage +): ClaudeUsage | undefined { + const input = extractClaudeUsageInput(message) + if (!input) return prev + return normalizeClaudeUsage(prev, input) +} diff --git a/shared/src/claudeUsageSchema.test.ts b/shared/src/claudeUsageSchema.test.ts new file mode 100644 index 0000000000..4ef2e61d1b --- /dev/null +++ b/shared/src/claudeUsageSchema.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' + +import { ClaudeUsageSchema, MetadataSchema } from './schemas' + +describe('ClaudeUsageSchema', () => { + it('accepts an empty payload with default rateLimits', () => { + const parsed = ClaudeUsageSchema.parse({}) + expect(parsed.rateLimits).toEqual({}) + }) + + it('parses a full payload including rate limits + per-model usage', () => { + const parsed = ClaudeUsageSchema.parse({ + contextWindow: { + usedTokens: 12345, + limitTokens: 200000, + percent: 6.17, + updatedAt: 1780000000000 + }, + rateLimits: { + session_5h: { + status: 'allowed_warning', + rateLimitType: 'session_5h', + utilization: 0.5, + updatedAt: 1780000000000, + resetsAt: 1780002000000 + }, + weekly_max: { + status: 'rejected', + rateLimitType: 'weekly_max', + utilization: 1, + updatedAt: 1780000000000 + } + }, + modelUsage: { + 'claude-sonnet-4-5': { + inputTokens: 100, + outputTokens: 200, + contextWindow: 200000, + maxOutputTokens: 8192, + costUSD: 0.1 + } + }, + totalCostUSD: 0.42, + resolvedModel: 'claude-sonnet-4-5' + }) + expect(parsed.rateLimits?.['session_5h']?.utilization).toBe(0.5) + expect(parsed.rateLimits?.['weekly_max']?.status).toBe('rejected') + expect(parsed.modelUsage?.['claude-sonnet-4-5']?.contextWindow).toBe(200000) + }) + + it('accepts unknown rateLimitType strings (record over opaque key)', () => { + const parsed = ClaudeUsageSchema.parse({ + rateLimits: { + future_quarterly: { + status: 'allowed_warning', + rateLimitType: 'future_quarterly', + utilization: 0.1, + updatedAt: 1 + } + } + }) + expect(Object.keys(parsed.rateLimits ?? {})).toContain('future_quarterly') + }) + + it('rejects unknown rate-limit status enum values', () => { + expect(() => ClaudeUsageSchema.parse({ + rateLimits: { + session_5h: { + status: 'totally-made-up', + rateLimitType: 'session_5h', + utilization: 0.1, + updatedAt: 1 + } + } + })).toThrow() + }) + + it('Metadata.claudeUsage round-trips through MetadataSchema', () => { + const parsed = MetadataSchema.parse({ + path: '/tmp', + host: 'h', + claudeUsage: { + rateLimits: { + session_5h: { + status: 'allowed_warning', + rateLimitType: 'session_5h', + utilization: 0.42, + updatedAt: 1 + } + } + } + }) + expect(parsed.claudeUsage?.rateLimits?.['session_5h']?.utilization).toBe(0.42) + }) +}) diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index acf4db1b78..01ef43513f 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -81,6 +81,62 @@ export const CodexUsageSchema = z.object({ export type CodexUsage = z.infer +// Claude rate-limit event surfaced from @anthropic-ai/claude-code via +// SDKMessage.type='rate_limit_event'. See cli/src/claude/utils/sdkToLogConverter.ts +// for the source-side handling (today only flattened into chat text; +// session.metadata.claudeUsage is the structured surface). +// +// rateLimitType is an opaque string set by Anthropic. Known values at +// time of writing: 'session_5h' (Pro/Max 5h rolling window), 'weekly_max' +// (weekly subscription window). Future variants are accepted as-is to +// avoid blocking the indicator on a shared-enum churn. +export const ClaudeRateLimitStatusSchema = z.enum(['allowed', 'allowed_warning', 'rejected']) + +export const ClaudeRateLimitSchema = z.object({ + status: ClaudeRateLimitStatusSchema, + resetsAt: z.number().optional(), + utilization: z.number(), + rateLimitType: z.string(), + updatedAt: z.number() +}) + +export type ClaudeRateLimit = z.infer + +// Per-model usage from SDKResultMessage.modelUsage[model]. All fields +// optional because the SDK does not guarantee every field on every +// turn (e.g. costUSD only appears on certain plan types; webSearchRequests +// only when web tools are invoked). +export const ClaudeModelUsageSchema = z.object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + cacheReadInputTokens: z.number().optional(), + cacheCreationInputTokens: z.number().optional(), + webSearchRequests: z.number().optional(), + costUSD: z.number().optional(), + contextWindow: z.number().optional(), + maxOutputTokens: z.number().optional() +}) + +export type ClaudeModelUsage = z.infer + +export const ClaudeUsageSchema = z.object({ + contextWindow: z.object({ + usedTokens: z.number(), + limitTokens: z.number(), + percent: z.number(), + updatedAt: z.number() + }).optional(), + // Keyed by rateLimitType string (e.g. 'session_5h' / 'weekly_max'). + // Record over the opaque-string key so the schema doesn't need an + // enum churn when Anthropic adds new rate-limit kinds. + rateLimits: z.record(z.string(), ClaudeRateLimitSchema).optional().default({}), + modelUsage: z.record(z.string(), ClaudeModelUsageSchema).optional(), + totalCostUSD: z.number().optional(), + resolvedModel: z.string().optional() +}) + +export type ClaudeUsage = z.infer + export const MetadataSchema = z.object({ path: z.string(), host: z.string(), @@ -113,7 +169,12 @@ export const MetadataSchema = z.object({ flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), worktree: WorktreeMetadataSchema.optional(), - codexUsage: CodexUsageSchema.optional() + // Per-flavor usage metadata for the agent budget indicator + // (web/src/components/AssistantChat/AgentBudgetIndicator). Each + // flavor populates its own structure; the indicator routes via + // flavor + adapter. + codexUsage: CodexUsageSchema.optional(), + claudeUsage: ClaudeUsageSchema.optional() }) export type Metadata = z.infer diff --git a/shared/src/types.ts b/shared/src/types.ts index 601302f244..8f9412e726 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -3,19 +3,13 @@ export type { AgentStateCompletedRequest, AgentStateRequest, AttachmentMetadata, + ClaudeModelUsage, + ClaudeRateLimit, + ClaudeUsage, CodexTokenUsage, CodexUsage, CodexUsageCredits, CodexUsageRateLimit, -} from './schemas' -export type { - AgentBudgetAxis, - AgentBudgetAxisId, - AgentBudgetEffectiveState, - AgentBudgetMetadataRow, - AgentBudgetState -} from './agentBudget' -export type { DecryptedMessage, Metadata, Machine, @@ -37,6 +31,14 @@ export type { WorktreeMetadata } from './schemas' +export type { + AgentBudgetAxis, + AgentBudgetAxisId, + AgentBudgetEffectiveState, + AgentBudgetMetadataRow, + AgentBudgetState +} from './agentBudget' + export type { SessionSummary, SessionSummaryMetadata, PendingRequestKind } from './sessionSummary' export { AGENT_MESSAGE_PAYLOAD_TYPE } from './modes' diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 78f4fc9865..59a47c1549 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -8,7 +8,9 @@ import { ScheduleTimePicker } from './ScheduleTimePicker' import type { PendingSchedule } from './ScheduleTimePicker' import { useFue } from '@/lib/use-fue' import { FueCallout, FueDot } from '@/components/Fue' +import type { ClaudeUsage } from '@hapi/protocol/types' import { AgentBudgetIndicator } from './AgentBudgetIndicator' +import { toClaudeBudgetState } from './claudeBudgetAdapter' import { toCodexBudgetState } from './codexBudgetAdapter' function VoiceAssistantIcon() { @@ -472,7 +474,13 @@ export function ComposerButtons(props: { scratchlistMode?: boolean scratchlistCount?: number onScratchlistToggle?: () => void + // Agent budget indicator: rendered next to the schedule button on + // remote sessions when usage telemetry is available for the flavor. + // The flavor switch lives here so future flavors (cursor / gemini) + // wire in symmetrically via their own adapters. + agentFlavor?: string | null codexUsage?: CodexUsage | null + claudeUsage?: ClaudeUsage | null }) { const { t } = useTranslation() const isVoiceConnected = props.voiceStatus === 'connected' @@ -622,7 +630,23 @@ export function ComposerButtons(props: {
- + {/* + * Agent budget indicator (umbrella tiann/hapi#846). Renders + * per-flavor budget pressure (context window, rate limits, + * credits) as a single small ring with state-driven colour. + * The flavor switch lives here so future flavors (cursor / + * gemini) wire in symmetrically via their own adapters. + * Hidden when the flavor has no telemetry available yet - + * adapter returns null and AgentBudgetIndicator no-ops. + */} + {props.agentFlavor === 'codex' ? ( + + ) : props.agentFlavor === 'claude' ? ( + + ) : null} void + // Per-flavor budget telemetry surfaced as the AgentBudgetIndicator on + // the composer toolbar. Each flavor populates its own field; the + // composer passes them through to ComposerButtons which routes via + // agentFlavor into the right adapter. + claudeUsage?: import('@hapi/protocol/types').ClaudeUsage | null // Set when the most recent send failed (4xx/5xx/network). The composer // restores the original text once per `sendError.id` and renders an // inline error affordance until the user dismisses or starts editing. @@ -1044,7 +1049,9 @@ export function HappyComposer(props: { scratchlistMode={props.scratchlistMode} scratchlistCount={props.scratchlistCount} onScratchlistToggle={props.onScratchlistToggle} + agentFlavor={agentFlavor} codexUsage={agentFlavor === 'codex' ? codexUsage : undefined} + claudeUsage={agentFlavor === 'claude' ? props.claudeUsage : undefined} />
diff --git a/web/src/components/AssistantChat/claudeBudgetAdapter.test.ts b/web/src/components/AssistantChat/claudeBudgetAdapter.test.ts new file mode 100644 index 0000000000..91fed13553 --- /dev/null +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' + +import type { ClaudeUsage } from '@hapi/protocol/types' + +import { toClaudeBudgetState } from './claudeBudgetAdapter' + +const empty = (): ClaudeUsage => ({ rateLimits: {} }) + +describe('toClaudeBudgetState', () => { + it('returns null when usage is undefined', () => { + expect(toClaudeBudgetState(undefined)).toBeNull() + expect(toClaudeBudgetState(null)).toBeNull() + }) + + it('returns null when there are no axes (no context, no rate limits)', () => { + expect(toClaudeBudgetState(empty())).toBeNull() + }) + + it('produces a context-only state with green effective when fresh', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 20000, limitTokens: 200000, percent: 10, updatedAt: 1 }, + rateLimits: {} + }) + expect(state).not.toBeNull() + expect(state?.axes.map((a) => a.id)).toEqual(['context']) + expect(state?.operationalAxisId).toBe('context') + expect(state?.effective).toBe('green') + expect(state?.axes[0]?.valueText).toBe('10%') + }) + + it('marks amber when context crosses 60%', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 130000, limitTokens: 200000, percent: 65, updatedAt: 1 }, + rateLimits: {} + }) + expect(state?.effective).toBe('amber') + }) + + it('marks red when context crosses 90%', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 190000, limitTokens: 200000, percent: 95, updatedAt: 1 }, + rateLimits: {} + }) + expect(state?.effective).toBe('red') + }) + + it('renders 5h + weekly rate-limit axes with stable ordering', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 20000, limitTokens: 200000, percent: 10, updatedAt: 1 }, + rateLimits: { + weekly_max: { status: 'allowed_warning', rateLimitType: 'weekly_max', utilization: 0.7, updatedAt: 1, resetsAt: 1780000000000 }, + session_5h: { status: 'allowed_warning', rateLimitType: 'session_5h', utilization: 0.4, updatedAt: 1 } + } + }) + expect(state?.axes.map((a) => a.id)).toEqual(['context', 'fiveHour', 'weekly']) + expect(state?.dominantAxisId).toBe('weekly') + expect(state?.effective).toBe('amber') + }) + + it('marks blocked + critical when a rate limit has status=rejected', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 20000, limitTokens: 200000, percent: 10, updatedAt: 1 }, + rateLimits: { + weekly_max: { status: 'rejected', rateLimitType: 'weekly_max', utilization: 1, updatedAt: 1, resetsAt: 1780000000000 } + } + }) + expect(state?.effective).toBe('blocked') + const weekly = state?.axes.find((a) => a.id === 'weekly') + expect(weekly?.critical).toBe(true) + expect(weekly?.valueText).toBe('Blocked') + expect(state?.effectiveReason).toContain('Weekly') + }) + + it('keeps the centre on context even when weekly is dominant', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 20000, limitTokens: 200000, percent: 10, updatedAt: 1 }, + rateLimits: { + weekly_max: { status: 'allowed_warning', rateLimitType: 'weekly_max', utilization: 0.85, updatedAt: 1 } + } + }) + expect(state?.operationalAxisId).toBe('context') + expect(state?.dominantAxisId).toBe('weekly') + }) + + it('falls back to the first axis as operational when context is missing', () => { + const state = toClaudeBudgetState({ + rateLimits: { + session_5h: { status: 'allowed_warning', rateLimitType: 'session_5h', utilization: 0.5, updatedAt: 1 } + } + }) + expect(state?.operationalAxisId).toBe('fiveHour') + }) + + it('labels unknown rateLimitType variants best-effort', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 0, limitTokens: 200000, percent: 0, updatedAt: 1 }, + rateLimits: { + custom_overlay: { status: 'allowed_warning', rateLimitType: 'custom_overlay', utilization: 0.3, updatedAt: 1 } + } + }) + const axis = state?.axes.find((a) => a.label === 'Custom Overlay') + expect(axis).toBeTruthy() + expect(axis?.id).toMatch(/^rateLimit:/) + }) + + it('renders a Cost metadata row when totalCostUSD > 0', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 10000, limitTokens: 200000, percent: 5, updatedAt: 1 }, + rateLimits: {}, + totalCostUSD: 0.42 + }) + const cost = state?.metadata?.find((m) => m.label === 'Cost (session)') + expect(cost?.value).toBe('$0.42') + }) + + it('renders a token breakdown row when modelUsage carries token counts', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 10000, limitTokens: 200000, percent: 5, updatedAt: 1 }, + rateLimits: {}, + modelUsage: { + 'claude-sonnet-4-5': { + inputTokens: 1234, + outputTokens: 5678, + cacheReadInputTokens: 90000 + } + } + }) + const tokens = state?.metadata?.find((m) => m.label === 'Tokens (session)') + expect(tokens?.value).toContain('in') + expect(tokens?.value).toContain('out') + expect(tokens?.value).toContain('cache') + }) + + it('shows resolved model in metadata when present', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 10000, limitTokens: 200000, percent: 5, updatedAt: 1 }, + rateLimits: {}, + resolvedModel: 'claude-sonnet-4-5' + }) + expect(state?.metadata?.find((m) => m.label === 'Model')?.value).toBe('claude-sonnet-4-5') + }) + + it('trims trailing zeros from cost display', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 10000, limitTokens: 200000, percent: 5, updatedAt: 1 }, + rateLimits: {}, + totalCostUSD: 0.5 + }) + const cost = state?.metadata?.find((m) => m.label === 'Cost (session)') + expect(cost?.value).toBe('$0.5') + }) + + it('does not render Cost row when totalCostUSD is zero', () => { + const state = toClaudeBudgetState({ + contextWindow: { usedTokens: 10000, limitTokens: 200000, percent: 5, updatedAt: 1 }, + rateLimits: {}, + totalCostUSD: 0 + }) + expect(state?.metadata?.find((m) => m.label === 'Cost (session)')).toBeUndefined() + }) +}) diff --git a/web/src/components/AssistantChat/claudeBudgetAdapter.ts b/web/src/components/AssistantChat/claudeBudgetAdapter.ts new file mode 100644 index 0000000000..76a8269a4c --- /dev/null +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.ts @@ -0,0 +1,242 @@ +import type { + AgentBudgetAxis, + AgentBudgetEffectiveState, + AgentBudgetMetadataRow, + AgentBudgetState, + ClaudeRateLimit, + ClaudeUsage +} from '@hapi/protocol/types' + +// Maps Claude SDK telemetry (rate_limit_event + assistant.usage + result.modelUsage) +// into the generic AgentBudgetState shape consumed by AgentBudgetIndicator. +// +// Differences vs Codex adapter (Phase A): +// - Claude does NOT have a credits axis. Rate-limit axes are session_5h and +// weekly_max only; when they hit `rejected`, the user is blocked (no +// credit-cover fallback as Codex has). +// - The SDK reports `rateLimitType` as an opaque string so adapter falls back +// to label-fy unknown types (e.g. 'opus_5h' → 'Opus 5h'). +// - Context window comes from SDK's reported `result.modelUsage[model].contextWindow`, +// not a hard-coded model→window map. +// - `effective` = green / amber / red / blocked, computed from worst axis: +// blocked = any rate limit `status === 'rejected'` +// red = any axis pressure >= 90 +// amber = any axis pressure >= 60 +// green = otherwise + +const AMBER_THRESHOLD = 60 +const RED_THRESHOLD = 90 + +// Known rateLimitType values from claude-code SDK at time of writing. +// Unknown values are accepted and labelled best-effort - schema does not +// constrain them (record over opaque string). +const RATE_LIMIT_LABELS: Record = { + session_5h: '5h Session', + five_hour: '5h Session', + weekly_max: 'Weekly', + weekly: 'Weekly', + opus_5h: 'Opus 5h', + opus_weekly: 'Opus Weekly' +} + +function clampPercent(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0 + return Math.max(0, Math.min(100, value)) +} + +function formatPercent(value: number): string { + return `${Math.round(clampPercent(value))}%` +} + +function formatTokens(tokens: number | undefined): string { + if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) return '0' + if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M` + if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 10_000 ? 0 : 1)}k` + return Math.round(tokens).toString() +} + +function formatResetTimestamp(resetsAt: number | undefined): string | undefined { + if (typeof resetsAt !== 'number' || resetsAt <= 0) return undefined + try { + const date = new Date(resetsAt * 1000 < 1e12 ? resetsAt * 1000 : resetsAt) + // Heuristic: Anthropic emits resetsAt as unix seconds in some flows + // and unix ms in others; the < 1e12 check picks the correct multiplier. + if (Number.isNaN(date.getTime())) return undefined + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) + } catch { + return undefined + } +} + +function formatRateLimitTypeLabel(rateLimitType: string): string { + const known = RATE_LIMIT_LABELS[rateLimitType] + if (known) return known + // Best-effort label for an unknown type: split on underscore, capitalise. + return rateLimitType + .split(/[_-]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function rateLimitPressure(limit: ClaudeRateLimit): number { + const util = typeof limit.utilization === 'number' ? limit.utilization : 0 + const pct = clampPercent(util > 1 ? util : util * 100) + if (limit.status === 'rejected') return 100 + return pct +} + +function rateLimitDetail(limit: ClaudeRateLimit): string | undefined { + const reset = formatResetTimestamp(limit.resetsAt) + if (limit.status === 'rejected') { + return reset ? `Blocked - resets ${reset}` : 'Blocked' + } + if (limit.status === 'allowed_warning') { + return reset ? `Resets ${reset}` : 'Warning' + } + return reset ? `Resets ${reset}` : undefined +} + +function deriveEffective( + axes: AgentBudgetAxis[], + rateLimits: Record +): { effective: AgentBudgetEffectiveState; reason: string } { + const rejected = Object.values(rateLimits).find((l) => l.status === 'rejected') + if (rejected) { + const label = formatRateLimitTypeLabel(rejected.rateLimitType) + const reset = formatResetTimestamp(rejected.resetsAt) + return { + effective: 'blocked', + reason: reset + ? `${label} limit reached - resets ${reset}` + : `${label} limit reached` + } + } + if (axes.length === 0) { + return { effective: 'green', reason: 'No usage telemetry yet' } + } + const dominant = axes.reduce((acc, axis) => (axis.pressure > acc.pressure ? axis : acc), axes[0]) + if (dominant.pressure >= RED_THRESHOLD) { + return { + effective: 'red', + reason: `${dominant.label} at ${formatPercent(dominant.pressure)}` + } + } + if (dominant.pressure >= AMBER_THRESHOLD) { + return { + effective: 'amber', + reason: `${dominant.label} at ${formatPercent(dominant.pressure)}` + } + } + return { + effective: 'green', + reason: `${dominant.label} at ${formatPercent(dominant.pressure)}` + } +} + +export function toClaudeBudgetState(usage: ClaudeUsage | undefined | null): AgentBudgetState | null { + if (!usage) return null + + const axes: AgentBudgetAxis[] = [] + + if (usage.contextWindow && usage.contextWindow.limitTokens > 0) { + const cw = usage.contextWindow + axes.push({ + id: 'context', + label: 'Context', + pressure: clampPercent(cw.percent), + valueText: formatPercent(cw.percent), + detail: `${formatTokens(cw.usedTokens)} / ${formatTokens(cw.limitTokens)} tokens` + }) + } + + const rateLimits = usage.rateLimits ?? {} + const rateEntries = Object.values(rateLimits) + // Render rate-limit axes in a stable order: known types first, then unknowns alphabetised. + const knownOrder = ['session_5h', 'five_hour', 'weekly_max', 'weekly', 'opus_5h', 'opus_weekly'] + const sortedRateLimits = [...rateEntries].sort((a, b) => { + const ai = knownOrder.indexOf(a.rateLimitType) + const bi = knownOrder.indexOf(b.rateLimitType) + if (ai !== -1 && bi !== -1) return ai - bi + if (ai !== -1) return -1 + if (bi !== -1) return 1 + return a.rateLimitType.localeCompare(b.rateLimitType) + }) + + for (const limit of sortedRateLimits) { + const pressure = rateLimitPressure(limit) + // Map opaque type to a stable axis id so dominant-axis comparison stays + // consistent across rerenders (would otherwise flicker on type renames). + const axisId = limit.rateLimitType.startsWith('session') || limit.rateLimitType === 'five_hour' + ? 'fiveHour' + : limit.rateLimitType.includes('weekly') + ? 'weekly' + : `rateLimit:${limit.rateLimitType}` + axes.push({ + id: axisId, + label: formatRateLimitTypeLabel(limit.rateLimitType), + pressure, + valueText: limit.status === 'rejected' ? 'Blocked' : formatPercent(pressure), + detail: rateLimitDetail(limit), + critical: limit.status === 'rejected' + }) + } + + if (axes.length === 0) return null + + const metadata: AgentBudgetMetadataRow[] = [] + if (typeof usage.totalCostUSD === 'number' && usage.totalCostUSD > 0) { + metadata.push({ + label: 'Cost (session)', + value: `$${usage.totalCostUSD.toFixed(4).replace(/\.?0+$/, '')}` + }) + } + if (usage.resolvedModel) { + metadata.push({ label: 'Model', value: usage.resolvedModel }) + } + if (usage.modelUsage) { + const tokens = Object.values(usage.modelUsage).reduce( + (acc, entry) => { + acc.input += entry.inputTokens ?? 0 + acc.output += entry.outputTokens ?? 0 + acc.cacheRead += entry.cacheReadInputTokens ?? 0 + acc.cacheCreation += entry.cacheCreationInputTokens ?? 0 + return acc + }, + { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } + ) + if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheCreation > 0) { + metadata.push({ + label: 'Tokens (session)', + value: `in ${formatTokens(tokens.input)} · out ${formatTokens(tokens.output)} · cache ${formatTokens(tokens.cacheRead + tokens.cacheCreation)}` + }) + } + } + + const { effective, reason } = deriveEffective(axes, rateLimits) + + // Operational axis = context if we have it, else the first rate-limit + // axis we found. The renderer pins the centre number to this axis's + // pressure so the gauge meaning is stable across all states. + const contextAxis = axes.find((a) => a.id === 'context') + const operationalAxisId = contextAxis ? 'context' : axes[0].id + + const dominantAxisId = axes.reduce( + (acc, axis) => (axis.pressure > acc.pressure ? axis : acc), + axes[0] + ).id + + return { + operationalAxisId, + axes, + effective, + effectiveReason: reason, + dominantAxisId, + metadata: metadata.length > 0 ? metadata : undefined + } +} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 4fbd018074..62461dea84 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -1091,6 +1091,7 @@ function SessionChatInner(props: SessionChatProps) { contextCacheRead={reduced.latestUsage?.cacheRead} contextWindow={reduced.latestUsage?.contextWindow} codexUsage={agentFlavor === 'codex' ? props.session.metadata?.codexUsage : undefined} + claudeUsage={agentFlavor === 'claude' ? props.session.metadata?.claudeUsage : undefined} controlledByUser={controlledByUser} onCollaborationModeChange={ codexCollaborationModeSupported && props.session.active && !controlledByUser From 989216f2169b8787083496570823b274bb7d02e7 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:08:15 +0100 Subject: [PATCH 16/19] fix(claude-indicator): correct timestamp parsing + relative time for reset dates The old heuristic (resetsAt * 1000 < 1e12) was inverted: current unix seconds (~1.78e9) * 1000 = ~1.78e12 > 1e12, so the code took the 'treat as ms' branch and fed ~1.78 billion ms to new Date(), yielding Jan 1970. Fix: use resetsAt < 1e10 as the seconds-vs-ms threshold. Unix seconds for the next several decades stay below 2e9 << 1e10; unix ms stay above 1e12. Also change the reset display from absolute date ('Jan 21, 3:43 PM') to relative time ('resets in 4h 32m') with the full absolute date surfaced as a browser tooltip (title attribute) on hover. Adds detailTitle to AgentBudgetAxis for this purpose and wires it in AgentBudgetIndicator. Co-authored-by: Cursor --- shared/src/agentBudget.ts | 5 +- .../AssistantChat/AgentBudgetIndicator.tsx | 11 ++- .../AssistantChat/claudeBudgetAdapter.ts | 69 ++++++++++++++----- 3 files changed, 65 insertions(+), 20 deletions(-) diff --git a/shared/src/agentBudget.ts b/shared/src/agentBudget.ts index 3b06ab5608..89698dfc91 100644 --- a/shared/src/agentBudget.ts +++ b/shared/src/agentBudget.ts @@ -64,8 +64,11 @@ export type AgentBudgetAxis = { // The renderer should not re-derive this from pressure. valueText: string // Optional supplemental string for the popover (e.g. - // '54k / 258k tokens', 'resets Apr 27, 1:00 PM'). + // '54k / 258k tokens', 'resets in 4h 32m'). detail?: string + // HTML title for the detail text (shown as browser tooltip on hover). + // Used to surface the absolute timestamp when detail shows relative time. + detailTitle?: string // True when this axis is covering for another exhausted axis // (e.g. credits remaining substituting for an exhausted Codex Pro // weekly window). The popover highlights covering axes so the user diff --git a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx index ad5d4e5ef5..a582216370 100644 --- a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx +++ b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx @@ -157,9 +157,14 @@ export function AgentBudgetIndicator(props: { state: AgentBudgetState | null | u >
{axis.label}
- {axis.detail ? ( -
{axis.detail}
- ) : null} + {axis.detail ? ( +
+ {axis.detail} +
+ ) : null}
{axis.valueText}
diff --git a/web/src/components/AssistantChat/claudeBudgetAdapter.ts b/web/src/components/AssistantChat/claudeBudgetAdapter.ts index 76a8269a4c..1685675af8 100644 --- a/web/src/components/AssistantChat/claudeBudgetAdapter.ts +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.ts @@ -55,19 +55,52 @@ function formatTokens(tokens: number | undefined): string { return Math.round(tokens).toString() } -function formatResetTimestamp(resetsAt: number | undefined): string | undefined { - if (typeof resetsAt !== 'number' || resetsAt <= 0) return undefined +// Seconds < 1e10 (all unix dates for the next several decades). +// Milliseconds >= 1e12. Anything between is ambiguous but treated as seconds +// since ms would imply ~1971 which is never a valid reset date. +function normalizeTimestampMs(resetsAt: number): number { + return resetsAt < 1e10 ? resetsAt * 1000 : resetsAt +} + +function formatRelativeTime(ms: number): string { + const diff = ms - Date.now() + if (diff <= 0) return 'now' + const totalSecs = Math.round(diff / 1000) + const hours = Math.floor(totalSecs / 3600) + const mins = Math.floor((totalSecs % 3600) / 60) + if (hours >= 24) { + const days = Math.floor(hours / 24) + return `in ${days}d ${hours % 24}h` + } + if (hours > 0) return `in ${hours}h ${mins}m` + return `in ${mins}m` +} + +function formatAbsoluteTimestamp(ms: number): string { try { - const date = new Date(resetsAt * 1000 < 1e12 ? resetsAt * 1000 : resetsAt) - // Heuristic: Anthropic emits resetsAt as unix seconds in some flows - // and unix ms in others; the < 1e12 check picks the correct multiplier. - if (Number.isNaN(date.getTime())) return undefined - return date.toLocaleString(undefined, { - month: 'short', + return new Date(ms).toLocaleString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }) + } catch { + return '' + } +} + +function formatResetDetail(resetsAt: number | undefined): { detail: string; detailTitle: string } | undefined { + if (typeof resetsAt !== 'number' || resetsAt <= 0) return undefined + try { + const ms = normalizeTimestampMs(resetsAt) + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return undefined + return { + detail: `resets ${formatRelativeTime(ms)}`, + detailTitle: formatAbsoluteTimestamp(ms) + } } catch { return undefined } @@ -91,15 +124,17 @@ function rateLimitPressure(limit: ClaudeRateLimit): number { return pct } -function rateLimitDetail(limit: ClaudeRateLimit): string | undefined { - const reset = formatResetTimestamp(limit.resetsAt) +function rateLimitDetail(limit: ClaudeRateLimit): { detail?: string; detailTitle?: string } { + const reset = formatResetDetail(limit.resetsAt) if (limit.status === 'rejected') { - return reset ? `Blocked - resets ${reset}` : 'Blocked' + return reset + ? { detail: `Blocked · ${reset.detail}`, detailTitle: reset.detailTitle } + : { detail: 'Blocked' } } if (limit.status === 'allowed_warning') { - return reset ? `Resets ${reset}` : 'Warning' + return reset ? reset : { detail: 'Approaching limit' } } - return reset ? `Resets ${reset}` : undefined + return reset ? reset : {} } function deriveEffective( @@ -109,11 +144,11 @@ function deriveEffective( const rejected = Object.values(rateLimits).find((l) => l.status === 'rejected') if (rejected) { const label = formatRateLimitTypeLabel(rejected.rateLimitType) - const reset = formatResetTimestamp(rejected.resetsAt) + const reset = formatResetDetail(rejected.resetsAt) return { effective: 'blocked', reason: reset - ? `${label} limit reached - resets ${reset}` + ? `${label} limit reached · ${reset.detail}` : `${label} limit reached` } } @@ -177,12 +212,14 @@ export function toClaudeBudgetState(usage: ClaudeUsage | undefined | null): Agen : limit.rateLimitType.includes('weekly') ? 'weekly' : `rateLimit:${limit.rateLimitType}` + const rlDetail = rateLimitDetail(limit) axes.push({ id: axisId, label: formatRateLimitTypeLabel(limit.rateLimitType), pressure, valueText: limit.status === 'rejected' ? 'Blocked' : formatPercent(pressure), - detail: rateLimitDetail(limit), + detail: rlDetail.detail, + detailTitle: rlDetail.detailTitle, critical: limit.status === 'rejected' }) } From fd7bf633c29f03f5b5f5bce451a5bc2c0ec9c9a8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:14:00 +0100 Subject: [PATCH 17/19] fix(claude-usage): accumulate cost+tokens across turns, not overwrite SDK result.total_cost_usd is per-turn cost, not session-cumulative. Similarly, result.modelUsage[model].inputTokens/outputTokens/etc are per-turn counts. The previous implementation did a shallow merge (spread) which overwrote with the latest turn's values, causing 'Cost (session)' to actually show the last-turn cost. Fix: accumulate totalCostUSD and all token counts across result messages. contextWindow and maxOutputTokens are structural metadata - take latest. No hub restart needed (CLI-side change only; the hub just stores whatever claudeUsage arrives via update-metadata). Co-authored-by: Cursor --- cli/src/claude/utils/claudeUsage.test.ts | 28 ++++++++++++++++++------ cli/src/claude/utils/claudeUsage.ts | 22 +++++++++++++++++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/cli/src/claude/utils/claudeUsage.test.ts b/cli/src/claude/utils/claudeUsage.test.ts index f8e4bad941..7ec01b2e67 100644 --- a/cli/src/claude/utils/claudeUsage.test.ts +++ b/cli/src/claude/utils/claudeUsage.test.ts @@ -240,20 +240,34 @@ describe('normalizeClaudeUsage', () => { expect(withAssistant.contextWindow?.limitTokens).toBe(200000) }) - it('merges modelUsage entries shallowly', () => { + it('accumulates modelUsage token counts across turns', () => { const a = normalizeClaudeUsage(undefined, { - modelUsage: { 'claude-sonnet-4-5': { inputTokens: 100, costUSD: 0.01 } }, + modelUsage: { 'claude-sonnet-4-5': { inputTokens: 100, costUSD: 0.01, contextWindow: 200000 } }, occurredAt: 1 }) const b = normalizeClaudeUsage(a, { - modelUsage: { 'claude-sonnet-4-5': { outputTokens: 200, costUSD: 0.05 } }, + modelUsage: { 'claude-sonnet-4-5': { inputTokens: 80, outputTokens: 200, costUSD: 0.05 } }, occurredAt: 2 }) - expect(b.modelUsage?.['claude-sonnet-4-5']).toEqual({ - inputTokens: 100, - outputTokens: 200, - costUSD: 0.05 + const m = b.modelUsage?.['claude-sonnet-4-5'] + // Token counts summed across both turns + expect(m?.inputTokens).toBe(180) + expect(m?.outputTokens).toBe(200) + // costUSD accumulated; contextWindow kept from first turn (structural metadata) + expect(m?.costUSD).toBeCloseTo(0.06, 4) + expect(m?.contextWindow).toBe(200000) + }) + + it('accumulates totalCostUSD across turns', () => { + const a = normalizeClaudeUsage(undefined, { + totalCostUSD: 0.12, + occurredAt: 1 + }) + const b = normalizeClaudeUsage(a, { + totalCostUSD: 0.08, + occurredAt: 2 }) + expect(b.totalCostUSD).toBeCloseTo(0.20, 5) }) it('clamps context-window percent to 0..100 bounds', () => { diff --git a/cli/src/claude/utils/claudeUsage.ts b/cli/src/claude/utils/claudeUsage.ts index e61c26d81e..b74b52fb95 100644 --- a/cli/src/claude/utils/claudeUsage.ts +++ b/cli/src/claude/utils/claudeUsage.ts @@ -194,15 +194,33 @@ export function normalizeClaudeUsage( } if (input.modelUsage) { + // Accumulate token counts across turns. The SDK's result message gives + // per-turn modelUsage (NOT session-cumulative), so we sum rather than + // replace. contextWindow and maxOutputTokens are structural metadata + // that don't accumulate - take the latest value seen. const merged: ClaudeUsage['modelUsage'] = { ...next.modelUsage } for (const [model, usage] of Object.entries(input.modelUsage)) { - merged[model] = { ...(merged[model] ?? {}), ...usage } + const prev = merged[model] ?? {} + merged[model] = { + contextWindow: usage.contextWindow ?? prev.contextWindow, + maxOutputTokens: usage.maxOutputTokens ?? prev.maxOutputTokens, + inputTokens: (prev.inputTokens ?? 0) + (usage.inputTokens ?? 0), + outputTokens: (prev.outputTokens ?? 0) + (usage.outputTokens ?? 0), + cacheReadInputTokens: (prev.cacheReadInputTokens ?? 0) + (usage.cacheReadInputTokens ?? 0), + cacheCreationInputTokens: (prev.cacheCreationInputTokens ?? 0) + (usage.cacheCreationInputTokens ?? 0), + webSearchRequests: (prev.webSearchRequests ?? 0) + (usage.webSearchRequests ?? 0), + // costUSD also per-turn from SDK; accumulate it separately from + // totalCostUSD so the adapter can cross-check if needed. + costUSD: (prev.costUSD ?? 0) + (usage.costUSD ?? 0) + } } next.modelUsage = merged } if (typeof input.totalCostUSD === 'number') { - next.totalCostUSD = input.totalCostUSD + // SDK result.total_cost_usd is per-turn cost, not session-cumulative. + // Accumulate across turns to get the true session total. + next.totalCostUSD = (prev?.totalCostUSD ?? 0) + input.totalCostUSD } if (input.assistantUsage) { From 4adfe795c50207ed822061032c4b34d76d46f097 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:28:41 +0100 Subject: [PATCH 18/19] feat(claude-indicator): tooltip on Cost row clarifies connection-scoped accumulation Co-authored-by: Cursor --- shared/src/agentBudget.ts | 2 ++ web/src/components/AssistantChat/AgentBudgetIndicator.tsx | 2 +- web/src/components/AssistantChat/claudeBudgetAdapter.ts | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/shared/src/agentBudget.ts b/shared/src/agentBudget.ts index 89698dfc91..0985a93395 100644 --- a/shared/src/agentBudget.ts +++ b/shared/src/agentBudget.ts @@ -86,6 +86,8 @@ export type AgentBudgetMetadataRow = { label: string value: string detail?: string + // HTML title attribute on the row (shown as browser tooltip on hover). + title?: string } export type AgentBudgetState = { diff --git a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx index a582216370..5c0872f0e6 100644 --- a/web/src/components/AssistantChat/AgentBudgetIndicator.tsx +++ b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx @@ -173,7 +173,7 @@ export function AgentBudgetIndicator(props: { state: AgentBudgetState | null | u {state.metadata && state.metadata.length > 0 ? (
{state.metadata.map((row) => ( -
+
{row.label}
{row.detail ? ( diff --git a/web/src/components/AssistantChat/claudeBudgetAdapter.ts b/web/src/components/AssistantChat/claudeBudgetAdapter.ts index 1685675af8..3258cbb166 100644 --- a/web/src/components/AssistantChat/claudeBudgetAdapter.ts +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.ts @@ -230,7 +230,8 @@ export function toClaudeBudgetState(usage: ClaudeUsage | undefined | null): Agen if (typeof usage.totalCostUSD === 'number' && usage.totalCostUSD > 0) { metadata.push({ label: 'Cost (session)', - value: `$${usage.totalCostUSD.toFixed(4).replace(/\.?0+$/, '')}` + value: `$${usage.totalCostUSD.toFixed(4).replace(/\.?0+$/, '')}`, + title: 'Accumulated since this CLI connection. Resets to $0 if the session disconnects from hub.' }) } if (usage.resolvedModel) { From b9b139ff6845bc571358f80983df162fb2493b80 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:08:01 +0100 Subject: [PATCH 19/19] fix(claude-indicator): round cost to 2dp ($10.11 not $10.1073) Co-authored-by: Cursor --- .../components/AssistantChat/claudeBudgetAdapter.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/src/components/AssistantChat/claudeBudgetAdapter.ts b/web/src/components/AssistantChat/claudeBudgetAdapter.ts index 3258cbb166..78fd33cbdf 100644 --- a/web/src/components/AssistantChat/claudeBudgetAdapter.ts +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.ts @@ -106,6 +106,14 @@ function formatResetDetail(resetsAt: number | undefined): { detail: string; deta } } +function formatCostUSD(usd: number): string { + if (usd === 0) return '$0.00' + const twoDP = usd.toFixed(2) + // Sub-cent amounts that round to $0.00 get 4dp to preserve meaning + if (twoDP === '0.00') return `$${usd.toFixed(4).replace(/0+$/, '')}` + return `$${twoDP}` +} + function formatRateLimitTypeLabel(rateLimitType: string): string { const known = RATE_LIMIT_LABELS[rateLimitType] if (known) return known @@ -230,7 +238,7 @@ export function toClaudeBudgetState(usage: ClaudeUsage | undefined | null): Agen if (typeof usage.totalCostUSD === 'number' && usage.totalCostUSD > 0) { metadata.push({ label: 'Cost (session)', - value: `$${usage.totalCostUSD.toFixed(4).replace(/\.?0+$/, '')}`, + value: formatCostUSD(usage.totalCostUSD), title: 'Accumulated since this CLI connection. Resets to $0 if the session disconnects from hub.' }) }