Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/.pi/
/downloads/
/.codexhost/
/.claude/

# Environment and secrets
.env
Expand Down
34 changes: 17 additions & 17 deletions openspec/changes/project-claude-code-session-usage/tasks.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
## 1. Contracts

- [ ] 1.1 Extend `HostUsage` / `parseHostUsage` with optional `planFiveHourUsedPercent`, `planFiveHourResetsAtUnix`, `planSevenDayUsedPercent`, and `planSevenDayResetsAtUnix`
- [ ] 1.2 Mirror those fields on `threadUsageSnapshotSchema` and reject reset-without-percent, out-of-range percent, and unknown keys
- [ ] 1.3 Add harness-adapter and shared-contracts tests for valid plan windows, CH 0–100, and incomplete snapshots
- [x] 1.1 Extend `HostUsage` / `parseHostUsage` with optional `planFiveHourUsedPercent`, `planFiveHourResetsAtUnix`, `planSevenDayUsedPercent`, and `planSevenDayResetsAtUnix`
- [x] 1.2 Mirror those fields on `threadUsageSnapshotSchema` and reject reset-without-percent, out-of-range percent, and unknown keys
- [x] 1.3 Add harness-adapter and shared-contracts tests for valid plan windows, CH 0–100, and incomplete snapshots

## 2. Claude Adapter

- [ ] 2.1 Parse Turn `result.total_cost_usd` and per-model `modelUsage` input/output into Session aggregate fields; do not map last-request `usage` onto Session I/O
- [ ] 2.2 Compute `cacheHitRatePercent` only from last-request or `getContextUsage().apiUsage` cache/input fields; omit Claude `cachedInputTokens`, `cacheWriteInputTokens`, and `reasoningOutputTokens`
- [ ] 2.3 Parse `rate_limit_event` for `five_hour` and `seven_day`, merge per-window, ignore other `rateLimitType` values
- [ ] 2.4 Keep Adapter-side latest snapshot merge so context refresh, Result totals, and plan events replace one `HostUsage` without dropping still-applicable fields
- [ ] 2.5 Preserve existing lazy Query, generation invalidation, and Usage failure isolation
- [ ] 2.6 Add Fake transport / Adapter tests covering OAuth-with-plan-window, API-key-without-plan-window, incomplete cache fields, stale context read, and malformed rate-limit events
- [x] 2.1 Parse Turn `result.total_cost_usd` and per-model `modelUsage` input/output into Session aggregate fields; do not map last-request `usage` onto Session I/O
- [x] 2.2 Compute `cacheHitRatePercent` only from last-request or `getContextUsage().apiUsage` cache/input fields; omit Claude `cachedInputTokens`, `cacheWriteInputTokens`, and `reasoningOutputTokens`
- [x] 2.3 Parse `rate_limit_event` for `five_hour` and `seven_day`, merge per-window, ignore other `rateLimitType` values
- [x] 2.4 Keep Adapter-side latest snapshot merge so context refresh, Result totals, and plan events replace one `HostUsage` without dropping still-applicable fields
- [x] 2.5 Preserve existing lazy Query, generation invalidation, and Usage failure isolation
- [x] 2.6 Add Fake transport / Adapter tests covering OAuth-with-plan-window, API-key-without-plan-window, incomplete cache fields, stale context read, and malformed rate-limit events

## 3. Renderer

- [ ] 3.1 Add five-hour and seven-day rows to the Usage details Popover; omit each row when its fields are absent
- [ ] 3.2 Keep the collapsed summary as `CH` and cost only, including when plan windows are present
- [ ] 3.3 Keep control visibility tied to CH, output speed, or cost
- [ ] 3.4 Extend Renderer Usage tests (and e2e coverage if present) for subscriber popover vs API-key omission vs summary text
- [x] 3.1 Add five-hour and seven-day rows to the Usage details Popover; omit each row when its fields are absent
- [x] 3.2 Keep the collapsed summary as `CH` and cost only, including when plan windows are present
- [x] 3.3 Keep control visibility tied to CH, output speed, or cost
- [x] 3.4 Extend Renderer Usage tests (and e2e coverage if present) for subscriber popover vs API-key omission vs summary text

## 4. Validation

- [ ] 4.1 Confirm Host inspection still round-trips the new `HostUsage` fields through `threadUsageInspectionSchema` without writing `accountCredits`
- [ ] 4.2 Confirm Protocol Core `thread/tokenUsage/updated` still uses only the context pair / existing aggregate carrier
- [ ] 4.3 Run focused package tests and typecheck for harness-adapter, shared-contracts, adapter-claude-code, renderer-extension, and host-runtime as needed
- [ ] 4.4 Do not add live Claude OAuth or `/api/oauth/usage` calls to ordinary checks
- [x] 4.1 Confirm Host inspection still round-trips the new `HostUsage` fields through `threadUsageInspectionSchema` without writing `accountCredits`
- [x] 4.2 Confirm Protocol Core `thread/tokenUsage/updated` still uses only the context pair / existing aggregate carrier
- [x] 4.3 Run focused package tests and typecheck for harness-adapter, shared-contracts, adapter-claude-code, renderer-extension, and host-runtime as needed
- [x] 4.4 Do not add live Claude OAuth or `/api/oauth/usage` calls to ordinary checks
216 changes: 211 additions & 5 deletions packages/adapters/claude-code/src/claude-code-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
type HostItemOutcome,
type HostQuestionInteraction,
type HostReasoningItem,
type HostUsage,
type InspectHarnessInput,
type InteractionRespondAccepted,
type InteractionRespondCommand,
Expand All @@ -59,6 +60,7 @@ import {
nativeCheckpointRefSchema,
nativeSessionRefSchema,
nativeTurnRefSchema,
type AccountCreditsSnapshot,
type HarnessId,
type HarnessThinkingOptionId,
type HostInteractionId,
Expand Down Expand Up @@ -99,6 +101,7 @@ import type {
ClaudeInteractionRequest,
ClaudeInteractionResponse,
ClaudeModelInspector,
ClaudePlanLimitEvent,
ClaudeQuestionRequest,
ClaudeTransportFailureKind,
ClaudeTransportTurnResult,
Expand Down Expand Up @@ -307,6 +310,65 @@ function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

/**
* Cache hit rate for the latest request only, never a Session cumulative value.
* Every addend must be present; the denominator must be positive.
*/
function claudeCacheHitRatePercent(usage: {
inputTokens: number;
cacheCreationInputTokens: number;
cacheReadInputTokens: number;
}): number | undefined {
const denominator =
usage.inputTokens + usage.cacheCreationInputTokens + usage.cacheReadInputTokens;
if (denominator <= 0) return undefined;
return Math.min(100, Math.max(0, (usage.cacheReadInputTokens / denominator) * 100));
}

/**
* Projects the Adapter's cached plan-limit observation into the generic
* `AccountCreditsSnapshot` shape the Renderer's credits pill/popover expect.
* The 5-hour window leads (it's the more actionable of the two — it resets
* soonest); the 7-day window, when known, rides along as a secondary entry
* rather than a fabricated "product". Falls back to the 7-day window alone
* if a 5-hour observation hasn't arrived yet.
*/
function isoFromUnix(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString();
}

export function projectClaudePlanLimitToCredits(
planLimit: ClaudePlanLimitEvent | null,
): AccountCreditsSnapshot | null {
if (!planLimit) return null;
const { fiveHour, sevenDay } = planLimit;
if (!fiveHour && !sevenDay) return null;

const primary = fiveHour ?? sevenDay;
if (!primary) return null;
const periodType: AccountCreditsSnapshot["periodType"] = fiveHour ? "five_hour" : "seven_day";
const other = fiveHour && sevenDay ? sevenDay : undefined;

return {
usedPercent: primary.utilizationPercent,
periodType,
...(primary.resetsAtUnix !== undefined ? { resetsAt: isoFromUnix(primary.resetsAtUnix) } : {}),
...(other
? {
productUsage: [
{
product: "7-day window",
usagePercent: other.utilizationPercent,
...(other.resetsAtUnix !== undefined
? { resetsAt: isoFromUnix(other.resetsAtUnix) }
: {}),
},
],
}
: {}),
};
}

class ClaudeHarnessSession implements HarnessSession {
readonly harnessId: HarnessId = claudeCodeHarnessId;
readonly capabilities: HarnessSessionCapabilities = {
Expand All @@ -328,6 +390,7 @@ class ClaudeHarnessSession implements HarnessSession {
readonly #cwd: string;
readonly #nativeRef: NativeSessionRef;
readonly #onClosed: () => void;
readonly #onPlanLimitObserved: (planLimit: ClaudePlanLimitEvent) => void;
readonly #openMode: "create" | "resume";
readonly #randomUUID: () => string;
#requestedModel: HarnessModelRef | undefined;
Expand All @@ -347,6 +410,7 @@ class ClaudeHarnessSession implements HarnessSession {
#statePublished = false;
#transport: ClaudeTurnTransport | null = null;
#usageGeneration = 0;
#latestUsage: HostUsage | null = null;
#autonomousOrdinal = 0;
#occupancy = new ClaudeBackgroundOccupancy();
#continuationQuiescence: ReturnType<typeof setTimeout> | null = null;
Expand All @@ -356,6 +420,7 @@ class ClaudeHarnessSession implements HarnessSession {
dependencies: ClaudeAdapterDependencies,
closeTimeoutMs: number,
onClosed: () => void,
onPlanLimitObserved: (planLimit: ClaudePlanLimitEvent) => void,
options: {
openMode: "create" | "resume";
sessionId: string;
Expand All @@ -372,6 +437,7 @@ class ClaudeHarnessSession implements HarnessSession {
this.#readSessionMessages = dependencies.readSessionMessages;
this.#closeTimeoutMs = closeTimeoutMs;
this.#onClosed = onClosed;
this.#onPlanLimitObserved = onPlanLimitObserved;
this.#openMode = options.openMode;
this.#requestedModel = options.requestedModel;
this.#requestedPermissionModeId = options.requestedPermissionModeId;
Expand Down Expand Up @@ -1029,6 +1095,7 @@ class ClaudeHarnessSession implements HarnessSession {
permissionMode,
onPermissionModeChanged: (mode) => this.#handlePermissionModeChanged(mode),
onFault: () => this.#fault(faultError()),
onPlanLimit: (planLimit) => this.#handlePlanLimit(planLimit),
});
transport.setAutonomousTurnHandler((turn) => this.#handleAutonomousTurn(turn));
transport.setIdleTurnHandler({
Expand Down Expand Up @@ -1209,6 +1276,9 @@ class ClaudeHarnessSession implements HarnessSession {
case "interaction.closed":
this.#closeInteraction(active, event.requestId, event.reason);
return;
case "usage.result":
this.#applyResultUsage(active, event);
return;
}
}

Expand Down Expand Up @@ -1562,18 +1632,114 @@ class ClaudeHarnessSession implements HarnessSession {
return;
}
if (context === null) continue;
const usage = parseHostUsage({
contextUsedTokens: context.usedTokens,
contextWindowTokens: context.maxTokens,
});
this.#event({ type: "session.usage.changed", observedForTurnId: turnId, usage });
const cacheHitRatePercent = context.apiUsage
? claudeCacheHitRatePercent(context.apiUsage)
: undefined;
this.#mergeAndPublishUsage(
{
contextUsedTokens: context.usedTokens,
contextWindowTokens: context.maxTokens,
...(cacheHitRatePercent !== undefined ? { cacheHitRatePercent } : {}),
},
turnId,
);
return;
} catch {
// Context Usage is an independent, best-effort projection.
}
}
}

#applyResultUsage(
active: ActiveTurn,
event: Extract<ClaudeTurnEvent, { type: "usage.result" }>,
): void {
const delta: Partial<HostUsage> = {};
if (event.totalCostUsd !== undefined) delta.totalCostUsd = event.totalCostUsd;
if (event.modelUsage !== undefined) {
let inputTokens = 0;
let outputTokens = 0;
for (const model of event.modelUsage) {
inputTokens += model.inputTokens;
outputTokens += model.outputTokens;
}
if (Number.isSafeInteger(inputTokens) && Number.isSafeInteger(outputTokens)) {
delta.inputTokens = inputTokens;
delta.outputTokens = outputTokens;
}
}
if (event.lastRequestUsage) {
const cacheHitRatePercent = claudeCacheHitRatePercent(event.lastRequestUsage);
if (cacheHitRatePercent !== undefined) delta.cacheHitRatePercent = cacheHitRatePercent;
Comment on lines +1671 to +1673

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale cache-hit data after an incomplete request

When one request publishes a cache-hit percentage and a later request omits any required cache field, lastRequestUsage is absent and this branch leaves cacheHitRatePercent untouched; #mergeAndPublishUsage then carries the previous request's percentage forward even though the UI presents it as the latest request. Clear the old value once the newer result and context fallback cannot provide a complete breakdown.

AGENTS.md reference: AGENTS.md:L61-L66

Useful? React with 👍 / 👎.

}
this.#mergeAndPublishUsage(delta, active.command.turnId);
}

/**
* On-demand counterpart to the passive `rate_limit_event` push: asks this
* Session's live Transport to pull plan usage right now (if it has one —
* a Session with no Turn yet has never opened a live connection to pull
* through). Routes any answer through the same `#handlePlanLimit` path as
* the push, so it updates both the Adapter's shared cache and this
* Thread's own Usage snapshot identically either way.
*/
async refreshPlanLimit(): Promise<ClaudePlanLimitEvent | null> {
if (this.#phase !== "open" || !this.#transport) return null;
let planLimit: ClaudePlanLimitEvent | null;
try {
planLimit = await this.#transport.getPlanLimit();
} catch {
return null;
}
if (planLimit) this.#handlePlanLimit(planLimit);
return planLimit;
}

#handlePlanLimit(planLimit: ClaudePlanLimitEvent): void {
// Plan usage is account-wide, not Thread-scoped: forward every observation to the
// Adapter's shared cache regardless of this Session's own lifecycle phase.
this.#onPlanLimitObserved(planLimit);
if (this.#phase !== "open") return;
const delta: Partial<HostUsage> = {};
if (planLimit.fiveHour) {
delta.planFiveHourUsedPercent = planLimit.fiveHour.utilizationPercent;
if (planLimit.fiveHour.resetsAtUnix !== undefined) {
delta.planFiveHourResetsAtUnix = planLimit.fiveHour.resetsAtUnix;
}
}
if (planLimit.sevenDay) {
delta.planSevenDayUsedPercent = planLimit.sevenDay.utilizationPercent;
if (planLimit.sevenDay.resetsAtUnix !== undefined) {
delta.planSevenDayResetsAtUnix = planLimit.sevenDay.resetsAtUnix;
}
}
this.#mergeAndPublishUsage(delta, this.#active?.command.turnId);
}

/**
* Every Usage observation replaces `#latestUsage` in full: unaffected fields
* from the prior snapshot are carried forward, never cleared by an
* incomplete new observation.
*/
#mergeAndPublishUsage(
delta: Partial<HostUsage>,
turnId: TurnStartCommand["turnId"] | undefined,
): void {
if (Object.keys(delta).length === 0) return;
let usage: HostUsage;
try {
usage = parseHostUsage({ ...(this.#latestUsage ?? {}), ...delta });
} catch {
return;
}
this.#latestUsage = usage;
this.#event({
type: "session.usage.changed",
...(turnId !== undefined ? { observedForTurnId: turnId } : {}),
usage,
});
}

#finishFailed(active: ActiveTurn, error: HarnessError): void {
this.#finish(active, { status: "failed", error });
}
Expand Down Expand Up @@ -1728,6 +1894,7 @@ export class ClaudeCodeAdapter implements HarnessAdapter {
readonly #inspectors = new Set<ClaudeModelInspector>();
readonly #sessions = new Set<ClaudeHarnessSession>();
#closePromise: Promise<void> | null = null;
#latestPlanLimit: ClaudePlanLimitEvent | null = null;

constructor(options: ClaudeCodeAdapterOptions = {}, dependencies?: ClaudeAdapterDependencies) {
this.#closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
Expand Down Expand Up @@ -1986,6 +2153,7 @@ export class ClaudeCodeAdapter implements HarnessAdapter {
this.#dependencies,
this.#closeTimeoutMs,
() => this.#sessions.delete(session),
(planLimit) => this.#recordPlanLimit(planLimit),
{
openMode: input.kind === "create" ? "create" : "resume",
sessionId: forked?.ok
Expand All @@ -2004,6 +2172,44 @@ export class ClaudeCodeAdapter implements HarnessAdapter {
return { ok: true, value: session };
}

/**
* Plan usage (`credits()`) is scoped to the Adapter, not a single Thread: the
* Claude.ai 5-hour / 7-day windows are account-wide and shared by every
* concurrent Session, so an observation from any one of them updates the
* value every Thread reads.
*/
#recordPlanLimit(planLimit: ClaudePlanLimitEvent): void {
this.#latestPlanLimit = {
...(this.#latestPlanLimit ?? {}),
...(planLimit.fiveHour ? { fiveHour: planLimit.fiveHour } : {}),
...(planLimit.sevenDay ? { sevenDay: planLimit.sevenDay } : {}),
};
}

credits(): AccountCreditsSnapshot | null {
return projectClaudePlanLimitToCredits(this.#latestPlanLimit);
}

/**
* Unlike the passive `rate_limit_event` push, this asks an open Session's
* live Transport to pull plan usage on demand (Claude Code's `/usage`
* control channel) — so, like Grok's credits, a refresh can actually
* produce a fresher value instead of only replaying what was last
* observed. Plan usage is account-wide, so any one open Session's answer
* updates the value every Thread reads; the first Session that manages to
* answer wins and the rest are left untried.
*/
async refreshCredits(): Promise<AccountCreditsSnapshot | null> {
for (const session of this.#sessions) {
// `refreshPlanLimit` already records a non-null answer into `#latestPlanLimit`
// (it routes through `#handlePlanLimit`, same as the passive push) — this loop
// only needs to know when to stop trying further Sessions.
const planLimit = await session.refreshPlanLimit();
if (planLimit) break;
}
return this.credits();
}

close(): Promise<void> {
if (!this.#closePromise) {
this.#inspectionCache.clear();
Expand Down
Loading
Loading