Skip to content
Open
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
41 changes: 41 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ import {
} from "./lib/tabVisitHistory";
import { preparePrompt } from "./lib/promptPreparation";
import { warmNativeSkills, isNativeCommandPrompt } from "./lib/skills";
import { fetchClaudeRateLimits } from "./lib/rateLimitsFetch";
import {
formatUsageReport,
isUsageCommand,
USAGE_SNAPSHOT_MAX_AGE_MS,
} from "./lib/usage";
import { nativeSkillContextForSession } from "./lib/sessionSkills";
import {
loadSessionFolders,
Expand Down Expand Up @@ -3929,6 +3935,36 @@ export default function App({
[],
);

// The CLI answers /usage itself and never streams it, so the app does.
const showUsage = useCallback(
(sessionId: string) => {
void fetchClaudeRateLimits({ maxAgeMs: USAGE_SNAPSHOT_MAX_AGE_MS }).then(
(limits) => {
const session = sessionsRef.current.find((s) => s.id === sessionId);
if (
!session ||
session.harness !== "claude" ||
removingSessionIds.current.has(sessionId)
) {
return;
}
enqueueHarnessEvent(sessionId, {
type: "status",
text: formatUsageReport(
{ session, limits },
{
now: Date.now(),
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
),
});
flushHarnessEvents();
},
);
},
[enqueueHarnessEvent, flushHarnessEvents],
);

const onSubmit = useCallback(
(
sessionId: string,
Expand All @@ -3948,6 +3984,10 @@ export default function App({
if (removingSessionIds.current.has(sessionId)) return;
const storedCurrent = sessionsRef.current.find((s) => s.id === sessionId);
if (!storedCurrent) return;
if (storedCurrent.harness === "claude" && isUsageCommand(text)) {
showUsage(sessionId);
return;
}
const current = options?.buildTarget
? withPlanBuildTarget(storedCurrent, options.buildTarget)
: storedCurrent;
Expand Down Expand Up @@ -4456,6 +4496,7 @@ export default function App({
dismissNoticesForContinuedSession,
enqueueHarnessEvent,
flushHarnessEvents,
showUsage,
],
);

Expand Down
5 changes: 4 additions & 1 deletion src/chrome/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import { useComposerSkills } from "./useComposerSkills";
import { Popover } from "./Popover";
import { consumePlanCommand, PLAN_COMMAND } from "../lib/plan";
import { COMPACT_COMMAND, isCompactCommand } from "../lib/compact";
import { USAGE_COMMAND } from "../lib/usage";
import {
consumeSessionFolderCommand,
isSessionFolderCommand,
Expand Down Expand Up @@ -525,15 +526,17 @@ export function Composer({
SESSION_FOLDER_COMMAND,
PLAN_COMMAND,
COMPACT_COMMAND,
...(harness === "claude" ? [USAGE_COMMAND] : []),
...skills.filter(
(skill) =>
skill.kind === "native" ||
(skill.name !== PLAN_COMMAND.name &&
skill.name !== COMPACT_COMMAND.name &&
(harness !== "claude" || skill.name !== USAGE_COMMAND.name) &&
skill.name !== SESSION_FOLDER_COMMAND.name),
),
],
[skills],
[harness, skills],
);
const skillLimit = hasNativeCommands(harness)
? Number.POSITIVE_INFINITY
Expand Down
5 changes: 2 additions & 3 deletions src/lib/compact.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BuiltinSkill } from "./skills";
import { isStandaloneCommand, type BuiltinSkill } from "./skills";

export const COMPACT_COMMAND: BuiltinSkill = {
kind: "builtin",
Expand All @@ -9,7 +9,6 @@ export const COMPACT_COMMAND: BuiltinSkill = {
source: "monocode",
};

/** Match the standalone composer command without consuming ordinary prompt text. */
export function isCompactCommand(text: string): boolean {
return /^\s*\/compact\s*$/i.test(text);
return isStandaloneCommand(text, COMPACT_COMMAND.name);
}
50 changes: 50 additions & 0 deletions src/lib/harness/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,56 @@ describe("applyHarnessEvent context", () => {
});
});

describe("usage totals", () => {
it("folds result totals into the session and leaves blocks alone", () => {
let session = newSession("claude", "/repo");
session = applyHarnessEvent(session, {
type: "usage",
processCostUsd: 0.01,
processApiMs: 1_000,
turnTokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 },
});
session = applyHarnessEvent(session, {
type: "usage",
processCostUsd: 0.03,
processApiMs: 2_500,
turnTokens: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 },
});
expect(session.usage).toEqual({
costUsd: 0.03,
apiMs: 2_500,
tokens: { input: 2, output: 4, cacheRead: 6, cacheWrite: 8 },
lastProcessCostUsd: 0.03,
lastProcessApiMs: 2_500,
});
expect(session.blocks).toEqual([]);
});

it("adds a new child's counters on top of the old total after a restart", () => {
let session = newSession("claude", "/repo");
session = applyHarnessEvent(session, {
type: "usage",
processCostUsd: 0.5,
processApiMs: 100,
});
session = applyHarnessEvent(session, { type: "session.started" });
session = applyHarnessEvent(session, {
type: "usage",
processCostUsd: 0.6,
processApiMs: 200,
});
expect(session.usage?.costUsd).toBeCloseTo(1.1);
expect(session.usage?.apiMs).toBe(300);
});

it("ignores session.started before any usage arrived", () => {
const session = applyHarnessEvent(newSession("claude", "/repo"), {
type: "session.started",
});
expect(session.usage).toBeUndefined();
});
});

describe("applyHarnessEvent turn metrics", () => {
it("attaches provider metrics to the latest user turn", () => {
let session = appendUser(newSession("claude", "/repo"), "Explain this");
Expand Down
7 changes: 7 additions & 0 deletions src/lib/harness/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ToolPreview,
} from "../session";
import { mergeContextUsage } from "../contextUsage";
import { mergeSessionUsage, resetProcessCounters } from "../sessionUsage";
import { displayPath } from "../paths";
import {
composeToolTitle,
Expand Down Expand Up @@ -105,6 +106,12 @@ export function applyHarnessEvent(
window: event.window,
}),
};
case "usage":
return { ...session, usage: mergeSessionUsage(session.usage, event) };
case "session.started":
return session.usage
? { ...session, usage: resetProcessCounters(session.usage) }
: session;
case "turn.metrics":
return mergeTurnMetrics(session, event);
case "tasks.updated":
Expand Down
4 changes: 4 additions & 0 deletions src/lib/harness/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
assistantThinkingBlocks,
assistantToolUses,
contextFromResult,
usageFromResult,
contextUsedFromAssistant,
turnMetricsFromResult,
buildClaudeSpawnArgs,
Expand Down Expand Up @@ -759,6 +760,9 @@ function handleResult(live: Live, rec: Record<string, unknown>): void {
const context = contextFromResult(rec);
if (context) live.onEvent({ type: "context", ...context });
}
// Compaction spends real tokens too, so its result counts.
const usage = usageFromResult(rec);
if (usage) live.onEvent({ type: "usage", ...usage });
const metrics = turnMetricsFromResult(rec);
if (metrics) live.onEvent({ type: "turn.metrics", ...metrics });

Expand Down
39 changes: 39 additions & 0 deletions src/lib/harness/claudeLive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,45 @@ describe("claude subagents", () => {
},
);

it("emits session usage from the turn result and ignores subagent results", async () => {
const { events, turn } = await startTurn("s1");
emit({
type: "result",
subtype: "success",
session_id: "sess_1",
parent_tool_use_id: "agent_1",
total_cost_usd: 9,
duration_api_ms: 9_000,
});
emit({
type: "result",
subtype: "success",
session_id: "sess_1",
total_cost_usd: 0.013,
duration_api_ms: 2_751,
usage: {
input_tokens: 10,
output_tokens: 31,
cache_read_input_tokens: 19_640,
cache_creation_input_tokens: 139,
},
});
await turn;
expect(events.filter((event) => event.type === "usage")).toEqual([
{
type: "usage",
processCostUsd: 0.013,
processApiMs: 2_751,
turnTokens: {
input: 10,
output: 31,
cacheRead: 19_640,
cacheWrite: 139,
},
},
]);
});

it("keeps simultaneous child questions reachable in the single-question UI", async () => {
const { events, turn } = await startTurn("s1");
for (const id of ["child_a", "child_b"]) {
Expand Down
33 changes: 33 additions & 0 deletions src/lib/harness/claudeProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildClaudeUserMessage,
contextFromResult,
contextUsedFromAssistant,
usageFromResult,
extractExitPlanModePlan,
isClaudeInitMessage,
isSubagentMessage,
Expand Down Expand Up @@ -617,6 +618,38 @@ describe("contextUsedFromAssistant", () => {
});
});

describe("usageFromResult", () => {
it("reads cost, API time, and the four token counts", () => {
expect(
usageFromResult({
type: "result",
total_cost_usd: 0.0108758,
duration_ms: 1810,
duration_api_ms: 1759,
usage: {
input_tokens: 10,
cache_creation_input_tokens: 4522,
cache_read_input_tokens: 15118,
output_tokens: 62,
},
}),
).toEqual({
processCostUsd: 0.0108758,
processApiMs: 1759,
turnTokens: { input: 10, output: 62, cacheRead: 15118, cacheWrite: 4522 },
});
});

it("omits fields the result does not carry", () => {
expect(usageFromResult({ type: "result", total_cost_usd: 0.2 })).toEqual({
processCostUsd: 0.2,
});
expect(
usageFromResult({ type: "result", subtype: "success" }),
).toBeUndefined();
});
});

describe("contextFromResult", () => {
it("reads the window the CLI reports rather than a model table", () => {
const rec = {
Expand Down
27 changes: 27 additions & 0 deletions src/lib/harness/claudeProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ToolPreview,
TurnMetrics,
} from "../session";
import type { TurnUsage } from "../sessionUsage";
import { attachmentPathText } from "../attachments";
import { isTaskListToolName, taskListFromToolInput } from "../taskList";
import {
Expand Down Expand Up @@ -1090,3 +1091,29 @@ export function contextFromResult(
if (!used && !window) return undefined;
return { used: used > 0 ? used : undefined, window };
}

export function usageFromResult(
rec: Record<string, unknown>,
): TurnUsage | undefined {
const processCostUsd = optionalNumber(rec.total_cost_usd);
const processApiMs = optionalNumber(rec.duration_api_ms);
const usage = asRecord(rec.usage);
const turnTokens = usage
? {
input: numberField(usage, "input_tokens"),
output: numberField(usage, "output_tokens"),
cacheRead: numberField(usage, "cache_read_input_tokens"),
cacheWrite: numberField(usage, "cache_creation_input_tokens"),
}
: undefined;
if (processCostUsd == null && processApiMs == null && !turnTokens) {
return undefined;
}
return { processCostUsd, processApiMs, turnTokens };
}

function optionalNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
2 changes: 2 additions & 0 deletions src/lib/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
TurnIntent,
TurnMetrics,
} from "../session";
import type { TurnUsage } from "../sessionUsage";
import type { UserQuestion } from "../userQuestion";

export type HarnessEvent =
Expand Down Expand Up @@ -116,6 +117,7 @@ export type HarnessEvent =
}
/** Context-window level after the harness's latest request. */
| { type: "context"; used?: number; window?: number }
| ({ type: "usage" } & TurnUsage)
/** Provider token accounting for the active user turn. */
| ({ type: "turn.metrics" } & TurnMetrics);

Expand Down
Loading
Loading