Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
8f5f164
Add Codex session list/resume flow to web new-session UI
dsus4wang Apr 27, 2026
3f4f33d
restore Codex session history
dsus4wang Apr 27, 2026
2f32998
add Codex usage indicator
dsus4wang Apr 27, 2026
753b51b
feat(codex-usage): surface credits + reached-type for premium accounts
heavygee Jun 8, 2026
c448e53
fix(codex-usage): drop $ prefix + format credit balance
heavygee Jun 8, 2026
b0fad40
fix(codex-usage): ring shows worst constraint, not just context
heavygee Jun 8, 2026
97dc541
refactor(codex-usage): split ring meaning - centre = context, colour …
heavygee Jun 9, 2026
01396d3
fix(codex-usage): forward importHistory through machine RPC to spawnS…
heavygee Jun 10, 2026
3bfff65
fix(codex-usage): cold-review fixes - duplicate history, type, popove…
heavygee Jun 10, 2026
457814a
fix(codex-usage): scope session listing to workspace roots on --works…
heavygee Jun 10, 2026
5e41eee
fix(codex-usage): use selected session's path as spawn directory on r…
heavygee Jun 10, 2026
79a17b7
fix(web): propagate metadata fields in patchSessionSummary
heavygee Jun 10, 2026
fd7547c
fix(codex): prevent double-replay and stale-selection regressions
heavygee Jun 11, 2026
be0d991
fix(web): paginate Codex session picker to fetch all pages
heavygee Jun 11, 2026
179f7c4
feat(claude): wire claude rate-limit + context window into agent budg…
heavygee Jun 9, 2026
989216f
fix(claude-indicator): correct timestamp parsing + relative time for …
heavygee Jun 9, 2026
fd7bf63
fix(claude-usage): accumulate cost+tokens across turns, not overwrite
heavygee Jun 9, 2026
4adfe79
feat(claude-indicator): tooltip on Cost row clarifies connection-scop…
heavygee Jun 9, 2026
b9b139f
fix(claude-indicator): round cost to 2dp ($10.11 not $10.1073)
heavygee Jun 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathExistsRequest, PathExistsResponse>(RPC_METHODS.PathExists, async (params) => {
const rawPaths = Array.isArray(params?.paths) ? params.paths : []
Expand Down Expand Up @@ -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')
Expand All @@ -274,7 +281,8 @@ export class ApiMachineClient {
permissionMode,
token,
sessionType,
worktreeName
worktreeName,
importHistory
})

switch (result.type) {
Expand Down
21 changes: 21 additions & 0 deletions cli/src/claude/claudeRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -117,9 +118,29 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase {
let planModeToolCalls = new Set<string>();
let ongoingToolCalls = new Map<string, { parentToolCallId: string | null }>();

// 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;
Expand Down
304 changes: 304 additions & 0 deletions cli/src/claude/utils/claudeUsage.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading