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/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 61f15fe176..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 : [] @@ -249,7 +256,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 +281,8 @@ export class ApiMachineClient { permissionMode, token, sessionType, - worktreeName + worktreeName, + importHistory }) switch (result.type) { 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..7ec01b2e67 --- /dev/null +++ b/cli/src/claude/utils/claudeUsage.test.ts @@ -0,0 +1,304 @@ +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('accumulates modelUsage token counts across turns', () => { + const a = normalizeClaudeUsage(undefined, { + modelUsage: { 'claude-sonnet-4-5': { inputTokens: 100, costUSD: 0.01, contextWindow: 200000 } }, + occurredAt: 1 + }) + const b = normalizeClaudeUsage(a, { + modelUsage: { 'claude-sonnet-4-5': { inputTokens: 80, outputTokens: 200, costUSD: 0.05 } }, + occurredAt: 2 + }) + 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', () => { + 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..b74b52fb95 --- /dev/null +++ b/cli/src/claude/utils/claudeUsage.ts @@ -0,0 +1,263 @@ +/** + * 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) { + // 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)) { + 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') { + // 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) { + 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/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a02e23ed6a..1b7b1174aa 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -105,6 +105,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/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..de6b11ce5b 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']; @@ -75,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; @@ -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..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; @@ -40,6 +41,7 @@ export class CodexSession extends AgentSessionBase { modelReasoningEffort?: SessionModelReasoningEffort; collaborationMode?: EnhancedMode['collaborationMode']; replayTranscriptHistoryOnStart?: boolean; + importHistory?: boolean; }) { super({ api: opts.api, @@ -71,8 +73,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; @@ -127,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/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..785bfc45e9 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.test.ts @@ -0,0 +1,222 @@ +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(); + }); + + 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 new file mode 100644 index 0000000000..633d937b55 --- /dev/null +++ b/cli/src/codex/utils/codexUsage.ts @@ -0,0 +1,250 @@ +import type { CodexTokenUsage, CodexUsage, CodexUsageCredits, 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 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; + + 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 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, + limitTokens: contextLimit, + percent: Math.min(100, Math.max(0, (contextUsed / contextLimit) * 100)), + updatedAt: now + } + : undefined; + + 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/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 new file mode 100644 index 0000000000..f6579cc060 --- /dev/null +++ b/cli/src/modules/common/codexSessions.test.ts @@ -0,0 +1,103 @@ +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 { findCodexSessionTitle, 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' } }), + 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)) + await utimes(oldPath, oldDate, oldDate) + + 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']) + 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() + }) + + 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 new file mode 100644 index 0000000000..491322904f --- /dev/null +++ b/cli/src/modules/common/codexSessions.ts @@ -0,0 +1,462 @@ +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; + 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; + } + 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'); +} + +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[] = []; + + 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; +} + +async function readJsonlLines(filePath: string): 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); + } 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 === '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); + const title = text ? formatCodexSessionTitle(text) : null; + if (title) { + return { title, titleSource: 'agent' }; + } + } + + if (messageType === 'thread_started') { + const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id); + return id ? { id } : null; + } + } + + 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 lines: string[] | null; + try { + [stat, lines] = await Promise.all([ + fs.stat(filePath), + readJsonlLines(filePath) + ]); + } catch { + return null; + } + if (!lines) { + return null; + } + + 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.slice(0, 400)) { + const parsed = parseSessionLine(line); + if (!parsed) { + continue; + } + if (!id && parsed.id) { + id = parsed.id; + } + if (parsed.title && (!title || titleSourcePriority(parsed.titleSource) > titleSourcePriority(titleSource))) { + title = parsed.title; + titleSource = parsed.titleSource ?? titleSource; + } + 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, + 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 = {}, + 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) + : 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, 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); + + 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: 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, + isOld: entry.updatedAt < cutoff + })); + + 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; + + 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..4bdb10314a --- /dev/null +++ b/cli/src/modules/common/handlers/codexSessions.ts @@ -0,0 +1,29 @@ +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, + pathAllowed?: (path: string | null) => boolean | Promise +): void { + rpcHandlerManager.registerHandler('listCodexSessions', async (data) => { + logger.debug('List Codex sessions request'); + + try { + const result = await listCodexSessions(data ?? {}, pathAllowed); + 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..c7d6103a9b 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' @@ -12,9 +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, options.codexSessionPathAllowed) registerCursorModelHandlers(rpcHandlerManager) registerOpencodeModelHandlers(rpcHandlerManager) registerFileHandlers(rpcHandlerManager, workingDirectory) 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/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/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 0f37188721..46da142c0f 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, @@ -114,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 }> { @@ -121,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 @@ -246,6 +264,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/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 a61578211e..9c64e70468 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, @@ -501,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 }> { @@ -514,6 +517,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + importHistory, effort, permissionMode ) @@ -715,6 +719,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + false, session.effort ?? undefined, preferredPermissionMode ) @@ -1073,6 +1078,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..9245fc623c 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -49,7 +49,8 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.yolo, parsed.data.sessionType, parsed.data.worktreeName, - undefined, + parsed.data.resumeSessionId, + parsed.data.importHistory, parsed.data.effort ) return c.json(result) @@ -112,6 +113,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/agentBudget.ts b/shared/src/agentBudget.ts new file mode 100644 index 0000000000..0985a93395 --- /dev/null +++ b/shared/src/agentBudget.ts @@ -0,0 +1,117 @@ +// Cross-flavor agent budget gauge shape. Seeded by the Codex usage +// indicator (tiann/hapi#537 rebase) and intended to grow to cover the +// matching axes for Claude (5h subscription window + context), +// Cursor (premium request quota + context, gated on telemetry exposure), +// and Gemini (RPM/RPD + context) under umbrella tiann/hapi#846. +// +// The shape is deliberately flavor-agnostic: each agent flavor implements +// an adapter (toCodexBudgetState, toClaudeBudgetState, ...) that maps its +// provider-specific usage payload into this normalized form. The UI +// consumes only AgentBudgetState - it knows nothing about codex credits +// or claude rate-limit headers. +// +// Design rationale (operator review 2026-06-09): +// - One ring centre number = "how much room for THIS task" (operational +// axis, usually context window). Stays consistent regardless of +// account-level state so the gauge does not silently change meaning. +// - Ring colour = "are you about to be blocked" (effective state across +// ALL axes, computed by the flavor adapter which knows the specific +// blocking semantics, e.g. 'Codex Pro credits cover an exhausted +// subscription window so weekly=100% is amber, not red, while +// credits>0'). +// - Popover = full axis breakdown with the dominant axis marked. + +export type AgentBudgetAxisId = + | 'context' + | 'fiveHour' + | 'weekly' + | 'credits' + // Flavor-specific axes (e.g. 'cursorPremiumRequests', 'geminiRpm') + // 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. + | 'green' + // Approaching a cap on at least one axis, or covering-axis scenario + // (e.g. subscription window at 100% but credits available). User + // should be aware but is not blocked. + | 'amber' + // Very close to a cap on at least one axis; further work may hit + // the limit imminently. + | 'red' + // Hard block - no axis has remaining capacity, and there is no + // covering axis. The agent cannot proceed. + | 'blocked' + // No telemetry available for this flavor / account. Adapter returns + // null in this case; the indicator hides entirely rather than + // surfacing a false 'green'. + | 'unknown' + +export type AgentBudgetAxis = { + id: AgentBudgetAxisId + label: string + // 0-100; how close to the cap this axis is. For credit-balance axes + // where there is no declared capacity, the adapter chooses a + // pragmatic mapping (e.g. 0 when has-balance, 100 when zero) and + // sets covering=true to signal the axis is a fallback rather than + // a primary constraint. + pressure: number + // Pre-formatted display value (e.g. '21%', '250', '100% used'). + // The renderer should not re-derive this from pressure. + valueText: string + // Optional supplemental string for the popover (e.g. + // '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 + // understands why the effective state is amber rather than red. + covering?: boolean + // Flagged as critical by the adapter (e.g. hard block on this axis). + // The renderer paints critical-severity rows in red. + critical?: boolean +} + +// Non-pressure informational rows (e.g. token breakdown, last-turn +// usage). These render in the popover after the pressure axes but +// do not influence the ring centre, colour, or effective state. +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 = { + // Which axis to display as the always-visible ring centre number. + // Defaults to 'context' for LLM agents because that is the + // operationally relevant axis during active composition. + operationalAxisId: AgentBudgetAxisId + // All axes the adapter could populate. Order is significant: the + // popover renders axes top-to-bottom in this order. + axes: AgentBudgetAxis[] + // Worst-case state across all axes, computed by the flavor adapter + // using its specific blocking semantics. The renderer uses this + // to colour the ring (not the operational-axis pressure alone). + effective: AgentBudgetEffectiveState + // Human-readable explanation of why the effective state is what + // it is. Used as the ring's title / aria-label so hovering tells + // the user 'Weekly window at cap; credits covering overage' + // instead of just '21%'. + effectiveReason: string + // Which axis (if any) is currently the highest-pressure point. + // The popover marks this row with a left-accent so the user can + // see at a glance why the effective state landed where it did. + dominantAxisId?: AgentBudgetAxisId + // Non-pressure informational rows the flavor wants to surface + // (e.g. token-by-bucket breakdown for Codex). + metadata?: AgentBudgetMetadataRow[] +} diff --git a/shared/src/apiTypes.ts b/shared/src/apiTypes.ts index e9ea6a1fff..34fd227557 100644 --- a/shared/src/apiTypes.ts +++ b/shared/src/apiTypes.ts @@ -198,7 +198,9 @@ 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(), + importHistory: z.boolean().optional() }) export type SpawnSessionRequest = z.infer 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/codexUsageSchema.test.ts b/shared/src/codexUsageSchema.test.ts new file mode 100644 index 0000000000..0ee1078ffb --- /dev/null +++ b/shared/src/codexUsageSchema.test.ts @@ -0,0 +1,71 @@ +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 + } + }) + }) + + 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/index.ts b/shared/src/index.ts index b8f5e291db..fd765ea0bb 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './agentBudget' export * from './apiTypes' export * from './cursorCliSku' export * from './messages' 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/shared/src/schemas.ts b/shared/src/schemas.ts index f1087aeeea..01ef43513f 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -25,6 +25,118 @@ 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 + +// 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(), + limitTokens: z.number(), + percent: z.number(), + updatedAt: z.number() + }).optional(), + rateLimits: 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() +}) + +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(), @@ -56,7 +168,13 @@ export const MetadataSchema = z.object({ preferredPermissionMode: PermissionModeSchema.optional(), flavor: z.string().nullish(), capabilities: SessionCapabilitiesSchema.optional(), - worktree: WorktreeMetadataSchema.optional() + worktree: WorktreeMetadataSchema.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 @@ -218,6 +336,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/shared/src/types.ts b/shared/src/types.ts index b10060a40e..8f9412e726 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -3,6 +3,13 @@ export type { AgentStateCompletedRequest, AgentStateRequest, AttachmentMetadata, + ClaudeModelUsage, + ClaudeRateLimit, + ClaudeUsage, + CodexTokenUsage, + CodexUsage, + CodexUsageCredits, + CodexUsageRateLimit, DecryptedMessage, Metadata, Machine, @@ -24,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/api/client.ts b/web/src/api/client.ts index 2a7bc91209..3d91287a13 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,41 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: 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 }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, resumeSessionId, importHistory }) }) } + + 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/AssistantChat/AgentBudgetIndicator.tsx b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx new file mode 100644 index 0000000000..5c0872f0e6 --- /dev/null +++ b/web/src/components/AssistantChat/AgentBudgetIndicator.tsx @@ -0,0 +1,193 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { AgentBudgetAxis, AgentBudgetEffectiveState, AgentBudgetState } from '@hapi/protocol/types' + +// Flavor-agnostic budget indicator. Consumes a normalized AgentBudgetState +// (see shared/src/agentBudget.ts) so it works for any agent flavor that +// can produce that shape - Codex today, Claude / Cursor / Gemini under +// umbrella tiann/hapi#846 as their adapters land. +// +// Visual contract (operator review 2026-06-09): +// - Centre number = state.operationalAxisId's pressure (usually context). +// Stays consistent across all account states so the gauge never +// silently changes meaning between context-fill and usage-exhaustion. +// - Ring colour = state.effective (green / amber / red / blocked). +// Computed by the flavor adapter using its specific blocking rules +// (e.g. Codex Pro credits cover an exhausted subscription window so +// weekly=100% with credits>0 is amber, not red). +// - Popover = full axis breakdown + metadata rows, with the dominant +// axis row carrying a left-accent + bold so the user can see at a +// glance why the ring colour landed where it did. + +type EffectivePalette = { + ring: string + text: string + accent: string +} + +const PALETTE: Record = { + green: { ring: 'var(--app-link)', text: 'var(--app-hint)', accent: 'var(--app-link)' }, + amber: { ring: '#b45309', text: '#b45309', accent: '#b45309' }, + red: { ring: '#991b1b', text: '#991b1b', accent: '#991b1b' }, + blocked: { ring: '#991b1b', text: '#991b1b', accent: '#991b1b' }, + // Unknown should never reach the renderer (adapter returns null + // instead, hiding the indicator) - mapped to green defensively. + unknown: { ring: 'var(--app-link)', text: 'var(--app-hint)', accent: 'var(--app-link)' } +} + +function operationalAxis(state: AgentBudgetState): AgentBudgetAxis | undefined { + return state.axes.find((axis) => axis.id === state.operationalAxisId) +} + +export function AgentBudgetIndicator(props: { state: AgentBudgetState | null | undefined; popoverTitle?: string }) { + const [open, setOpen] = useState(false) + const [position, setPosition] = useState<{ left: number; bottom: number } | null>(null) + const buttonRef = useRef(null) + + 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]) + + 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 + const opAxis = operationalAxis(state) + // The ring centre is the operational axis pressure; ring fill is the + // EFFECTIVE pressure (max axis) so a red-effective state with low + // context still visibly fills the ring. Without this, an amber/red + // state with context=20 would render 'amber colour, 20% fill' which + // looks like a healthy account. + const opPressure = opAxis ? opAxis.pressure : 0 + const effectivePressure = Math.max(...state.axes.map((a) => a.pressure), 0) + const palette = PALETTE[state.effective] + const fillPercent = state.effective === 'blocked' ? 100 : Math.max(opPressure, effectivePressure) + const background = `conic-gradient(${palette.ring} ${fillPercent * 3.6}deg, var(--app-divider) 0deg)` + const centreNumber = Math.round(opPressure) + const tooltip = state.effectiveReason + + 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 5b0325a6f6..59a47c1549 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,10 @@ 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 type { ClaudeUsage } from '@hapi/protocol/types' +import { AgentBudgetIndicator } from './AgentBudgetIndicator' +import { toClaudeBudgetState } from './claudeBudgetAdapter' +import { toCodexBudgetState } from './codexBudgetAdapter' function VoiceAssistantIcon() { return ( @@ -426,6 +431,11 @@ export function UnifiedButton(props: { ) } +function CodexUsageIndicator(props: { usage?: CodexUsage | null }) { + const state = toCodexBudgetState(props.usage) + return +} + export function ComposerButtons(props: { canSend: boolean controlsDisabled: boolean @@ -464,6 +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' @@ -612,29 +629,48 @@ export function ComposerButtons(props: { ) : null} - +
+ {/* + * 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} + +
) } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 96ae5753f4..e3d6fee7e5 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 }> @@ -120,6 +122,11 @@ export function HappyComposer(props: { scratchlistMode?: boolean scratchlistCount?: number onScratchlistToggle?: () => 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. @@ -144,6 +151,7 @@ export function HappyComposer(props: { contextSize, contextCacheRead, contextWindow, + codexUsage, controlledByUser = false, agentFlavor, availableModelOptions, @@ -1041,6 +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..78fd33cbdf --- /dev/null +++ b/web/src/components/AssistantChat/claudeBudgetAdapter.ts @@ -0,0 +1,288 @@ +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() +} + +// 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 { + 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 + } +} + +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 + // 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): { detail?: string; detailTitle?: string } { + const reset = formatResetDetail(limit.resetsAt) + if (limit.status === 'rejected') { + return reset + ? { detail: `Blocked · ${reset.detail}`, detailTitle: reset.detailTitle } + : { detail: 'Blocked' } + } + if (limit.status === 'allowed_warning') { + return reset ? reset : { detail: 'Approaching limit' } + } + return reset ? reset : {} +} + +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 = formatResetDetail(rejected.resetsAt) + return { + effective: 'blocked', + reason: reset + ? `${label} limit reached · ${reset.detail}` + : `${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}` + const rlDetail = rateLimitDetail(limit) + axes.push({ + id: axisId, + label: formatRateLimitTypeLabel(limit.rateLimitType), + pressure, + valueText: limit.status === 'rejected' ? 'Blocked' : formatPercent(pressure), + detail: rlDetail.detail, + detailTitle: rlDetail.detailTitle, + 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: formatCostUSD(usage.totalCostUSD), + title: 'Accumulated since this CLI connection. Resets to $0 if the session disconnects from hub.' + }) + } + 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/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..93734d7a27 --- /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 || resetAt <= 0) 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/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..0596c3e170 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,22 @@ export function NewSession(props: { machineId, enabled: agent === 'codex' && Boolean(machineId) }) + + const codexSessionsState = useCodexSessions({ + api: props.api, + machineId, + includeOld: showOldCodexSessions, + 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), @@ -405,6 +428,7 @@ export function NewSession(props: { setMachineId(newMachineId) setModel('auto') setCursorSelectedBase('auto') + setSelectedCodexSessionId('') const paths = getRecentPaths(newMachineId) if (paths[0]) { setDirectory(paths[0]) @@ -526,12 +550,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') @@ -565,21 +599,23 @@ export function NewSession(props: { : undefined const result = await spawnSession({ machineId, - directory: trimmedDirectory, + directory: spawnDirectory, agent, model: resolvedModel, effort: resolvedEffort, 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, + importHistory: agent === 'codex' && Boolean(selectedCodexSessionId) }) if (result.type === 'success') { haptic.notification('success') clearNewSessionFormDraft() setLastUsedMachineId(machineId) - addRecentPath(machineId, trimmedDirectory) + addRecentPath(machineId, spawnDirectory) props.onSuccess(result.sessionId) return } @@ -592,7 +628,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 (
@@ -716,6 +753,18 @@ export function NewSession(props: { isDisabled={isFormDisabled} onEffortChange={setEffort} /> + + { 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, diff --git a/web/src/hooks/queries/useCodexSessions.ts b/web/src/hooks/queries/useCodexSessions.ts new file mode 100644 index 0000000000..6c7dbcb1f7 --- /dev/null +++ b/web/src/hooks/queries/useCodexSessions.ts @@ -0,0 +1,57 @@ +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') + } + 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, + 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/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 11569e43a3..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' @@ -64,7 +65,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 { @@ -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, 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