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
8 changes: 7 additions & 1 deletion src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`
}
Expand Down
9 changes: 7 additions & 2 deletions src/lib/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
41 changes: 37 additions & 4 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<number | null>(
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)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -406,6 +429,13 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
const pollStatus = useCallback(async (): Promise<void> => {
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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion test/output.test.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand All @@ -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')

Expand Down