diff --git a/src/commands/connect.ts b/src/commands/connect.ts index e8fd960..b4b7d45 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -3,7 +3,7 @@ import React from 'react' import { render } from 'ink' import { ApiClient } from '../lib/api' import { requireToken, resolveApiBase, resolveAppBase } from '../lib/config' -import { runAction } from '../lib/output' +import { runAction, usdNumberFromMillicents } from '../lib/output' import { foldCosts, isConnectVisibleRecord, recordToItems, type CCEvent } from '../lib/events' import { sessionUrl } from '../lib/urls' import { resolveWsBase } from '../lib/ws' @@ -122,6 +122,11 @@ export async function runConnect( .flatMap((st) => recordToItems(st, `s${st.feed_seq}`)) : [] const initialCost = foldCosts(ordered.map((st) => st.payload as CCEvent)) + // The server's ledger total at connect time; live updates arrive as + // cost_millicents on status/done frames. + const initialServerCostUsd = usdNumberFromMillicents( + session.cost_tokens + session.cost_sandbox_cpu + session.cost_sandbox_memory + session.cost_fee, + ) // Written by the app when it exits because the conversation closed (terminal; // nothing left to reconnect to), so the detach sign-off below stays honest. @@ -140,6 +145,7 @@ export async function runConnect( initialStatus: session.surface?.status ?? session.status, sessionUrl: url, initialCost, + initialServerCostUsd, initialNotice: reason ?? null, // The session's one model, fixed at creation (backend tokens_model). model: typeof session.tokens_model === 'string' ? session.tokens_model : null, diff --git a/src/lib/output.ts b/src/lib/output.ts index 10e9c0d..3e50c56 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -57,6 +57,12 @@ export function usdFromMillicents(millicents: number): string { return `$${(millicents / 100_000).toFixed(2)}` } +// Millicents -> USD as a number, for callers that do math/compare before +// formatting (e.g. the connect footer's monotonic live cost). +export function usdNumberFromMillicents(millicents: number): number { + return millicents / 100_000 +} + export function usd(amount: number): string { return `$${amount.toFixed(2)}` } diff --git a/src/lib/ws.ts b/src/lib/ws.ts index c39748e..95d8ec6 100644 --- a/src/lib/ws.ts +++ b/src/lib/ws.ts @@ -4,14 +4,17 @@ import { DEFAULT_WS_BASE, USER_AGENT } from './constants' // The frame protocol spoken over the session WebSocket (server -> client). One // JSON object per message. Mirrors session_stream.py in the backend. -// status: { type, status, session, run, ts } +// status: { type, status, session, run, cost_millicents, ts } // stdout/stderr: { type, data, seq, ts } // delta: { type, text?, output_tokens? } -// done: { type, status, session, run, exit_status } +// done: { type, status, session, run, exit_status, cost_millicents } // error: { type, message } // `status` is the derived single word (working/waiting/sleeping/starting/…); // `session` (alive/sleeping/closed) and `run` are the two raw axes. All three // are null for an un-keyed (laptop) session. See session_surface.py. +// `cost_millicents` is the session's running total cost from the server's spend +// ledger — the billing authority; it climbs mid-turn as the agent spends, and +// the server emits a status frame on every change (absent on older backends). // `seq` is a monotonic per-session cursor; the client resumes from the last seq // it saw via `?after_seq=` so a dropped socket loses nothing. `delta` is the // EPHEMERAL live-streaming frame (partial assistant text + running token count); @@ -24,6 +27,8 @@ export interface StreamFrame { // status/done frames: the two raw session-surface axes (null for laptop). session?: string | null run?: string | null + // status/done frames: the session's running total cost (millicents). + cost_millicents?: number seq?: number ts?: string message?: string diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 8f4d410..7b37636 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -17,6 +17,7 @@ import { type TranscriptItem, } from '../lib/events' import { hyperlink } from '../lib/urls' +import { usdNumberFromMillicents } from '../lib/output' import { VERSION } from '../lib/constants' // The interactive `agent session connect` UI, modelled on Claude Code: a @@ -54,6 +55,10 @@ export interface ConnectAppProps { sessionUrl: string // Spend seeded from the stored steps: cumulative total + the last turn's cost. initialCost: { total: number | null; lastStep: number | null } + // The server-reported session total (USD) at connect time, from the session's + // cost columns — the billing authority. Live updates arrive as + // `cost_millicents` on status/done frames; null against older backends. + initialServerCostUsd?: number | null // A one-line caveat shown as the app's opening notice (e.g. "watch-only: // this conversation is closed"). null for the normal connect. initialNotice?: string | null @@ -132,6 +137,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // the latest Claude Code result; `lastStep` is the cost of the most recent // turn (the delta between the last two results). const [cost, setCost] = useState(props.initialCost) + // The server's authoritative running total (USD), from `cost_millicents` on + // status/done frames (and the status poll as backstop). Preferred over the + // CC-derived total when present; kept monotonic so the footer never dips. + const [serverCostUsd, setServerCostUsd] = useState( + props.initialServerCostUsd ?? null, + ) + const applyServerCostUsd = useCallback((usd: number): void => { + setServerCostUsd((prev) => (prev == null ? usd : Math.max(prev, usd))) + }, []) // Live-flow state that must survive re-renders without triggering them. const afterSeq = useRef(0) @@ -305,6 +319,15 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const handleFrame = useCallback( (frame: StreamFrame): void => { if (typeof frame.seq === 'number') afterSeq.current = Math.max(afterSeq.current, frame.seq) + // status/done frames carry the server's running session total; the + // server emits a status frame on every cost change, so the footer's + // dollar figure climbs mid-turn as the agent spends. + if ( + (frame.type === 'status' || frame.type === 'done') && + typeof frame.cost_millicents === 'number' + ) { + applyServerCostUsd(usdNumberFromMillicents(frame.cost_millicents)) + } if (frame.type === 'status' && frame.status && frame.status !== lastStatus.current) { lastStatus.current = frame.status setStatus(frame.status) @@ -331,7 +354,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } scheduleRefresh() }, - [finishClosed, scheduleRefresh], + [applyServerCostUsd, finishClosed, scheduleRefresh], ) // Keep the socket attached across reconnects/resume. A keyed session going @@ -406,6 +429,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { const pollStatus = useCallback(async (): Promise => { try { const s = await api.getAgentSession(sessionId) + // Same figure the stream's cost_millicents carries, so the footer keeps + // climbing even when the socket never attached (polling fallback). + applyServerCostUsd( + usdNumberFromMillicents( + s.cost_tokens + s.cost_sandbox_cpu + s.cost_sandbox_memory + s.cost_fee, + ), + ) const word = s.surface?.status ?? s.status if (word !== lastStatus.current) { lastStatus.current = word @@ -417,7 +447,7 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { } catch { // Transient fetch failure — the next tick retries. } - }, [api, finishClosed, sessionId]) + }, [api, applyServerCostUsd, finishClosed, sessionId]) useEffect(() => { pump() @@ -595,8 +625,11 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { // The persistent footer status line, kept minimal: the current status and // the running spend (cumulative total + the last turn's cost). Session - // identity lives in the banner; command hints live in --help. - const totalStr = `$${(cost.total ?? 0).toFixed(2)}` + // identity lives in the banner; command hints live in --help. The total + // prefers the server's ledger figure (live via cost_millicents frames, + // climbing mid-turn); the CC-derived result total is the fallback against + // older backends. + const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}` const lastStepStr = cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : '' const metaLine = `${status} · ${totalStr} total${lastStepStr}` // Three distinct, factual activity signals — never whimsy. All render IN the diff --git a/test/output.test.ts b/test/output.test.ts index 9c10ea9..2707d8b 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { relativeAge, usd, usdFromMillicents } from '../src/lib/output' +import { relativeAge, usd, usdFromMillicents, usdNumberFromMillicents } from '../src/lib/output' describe('usdFromMillicents', () => { it('converts millicents to dollars (1 cent = 1000 millicents)', () => { @@ -9,6 +9,14 @@ describe('usdFromMillicents', () => { }) }) +describe('usdNumberFromMillicents', () => { + it('converts millicents to a USD number for math before formatting', () => { + expect(usdNumberFromMillicents(0)).toBe(0) + expect(usdNumberFromMillicents(100_000)).toBe(1) + expect(usdNumberFromMillicents(12_345_000)).toBeCloseTo(123.45) + }) +}) + describe('relativeAge', () => { const now = new Date('2026-07-06T12:00:00Z')