diff --git a/app/src/components/shell/sidebar.tsx b/app/src/components/shell/sidebar.tsx
index 0205e0a6d..6b36b105e 100644
--- a/app/src/components/shell/sidebar.tsx
+++ b/app/src/components/shell/sidebar.tsx
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { LayoutDashboard, Blend, Settings } from "lucide-react";
+import { LayoutDashboard, Blend, Settings, BarChart2 } from "lucide-react";
import { ConfirmDialog } from "@houston-ai/core";
import { AppSidebar, WorkspaceSwitcher } from "@houston-ai/layout";
import { useWorkspaceStore } from "../../stores/workspaces";
@@ -50,7 +50,7 @@ export function Sidebar({ children }: { children: ReactNode }) {
onShareAgent: (agentId) => useUIStore.getState().setShareAgentId(agentId),
shareLabel: t("portable:shareMenu"),
});
- const isTopLevel = viewMode === "dashboard" || viewMode === "connections" || viewMode === "settings";
+ const isTopLevel = viewMode === "dashboard" || viewMode === "connections" || viewMode === "settings" || viewMode === "usage";
const handleWorkspaceSwitch = async (wsId: string) => {
if (wsId === currentWorkspace?.id) return;
@@ -135,6 +135,12 @@ export function Sidebar({ children }: { children: ReactNode }) {
icon: ,
onClick: () => setViewMode("settings"),
},
+ {
+ id: "usage",
+ label: t("shell:sidebar.usage"),
+ icon: ,
+ onClick: () => setViewMode("usage"),
+ },
]}
activeNavId={isTopLevel ? viewMode : undefined}
sectionLabel={t("shell:sidebar.yourAgents")}
diff --git a/app/src/components/shell/workspace-shell.tsx b/app/src/components/shell/workspace-shell.tsx
index 8cc884ea2..9234da030 100644
--- a/app/src/components/shell/workspace-shell.tsx
+++ b/app/src/components/shell/workspace-shell.tsx
@@ -25,6 +25,7 @@ import { AgentRenderer } from "./experience-renderer";
import { Dashboard } from "../dashboard";
import { IntegrationsView } from "../tabs/integrations-view";
import { SettingsView } from "../settings/settings-view";
+import { UsageView } from "../usage-view";
import { Sidebar } from "./sidebar";
import { HoustonLogo } from "./experience-card";
import { CreateAgentDialog } from "./create-workspace-dialog";
@@ -70,7 +71,7 @@ export function WorkspaceShell({ toasts, onDismissToast }: WorkspaceShellProps)
const { data: activities } = useActivity(currentAgent?.folderPath);
const needsYouCount = (activities ?? []).filter((a) => a.status === "needs_you").length;
const isAgentView =
- viewMode !== "dashboard" && viewMode !== "connections" && viewMode !== "settings";
+ viewMode !== "dashboard" && viewMode !== "connections" && viewMode !== "settings" && viewMode !== "usage";
const tabOr = (id: string) => (STANDARD_TAB_IDS.has(id) ? id : DEFAULT_TAB_ID);
useEffect(() => {
@@ -122,6 +123,8 @@ export function WorkspaceShell({ toasts, onDismissToast }: WorkspaceShellProps)
) : viewMode === "settings" ? (
+ ) : viewMode === "usage" ? (
+
) : currentAgent && agentDef && isAgentView ? (
<>
diff --git a/app/src/components/usage-chart.tsx b/app/src/components/usage-chart.tsx
new file mode 100644
index 000000000..da4d4a01b
--- /dev/null
+++ b/app/src/components/usage-chart.tsx
@@ -0,0 +1,205 @@
+import { useEffect, useRef, useState } from "react";
+
+// SVG layout constants
+const VW = 480;
+const VH = 160;
+const PL = 42; // left padding for Y labels
+const PR = 8;
+const PT = 12;
+const PB = 28;
+const CW = VW - PL - PR;
+const CH = VH - PT - PB;
+
+function fmtTick(v: number): string {
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
+ if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K`;
+ if (v >= 1 && v < 1000) return v.toFixed(v < 10 ? 2 : 0);
+ if (v > 0 && v < 1) return v.toFixed(4);
+ return String(Math.round(v));
+}
+
+function niceMax(raw: number): number {
+ if (raw === 0) return 1;
+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
+ const norm = raw / mag;
+ const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
+ return nice * mag;
+}
+
+function mk(tag: string, attrs: Record
): SVGElement {
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
+ return el;
+}
+
+export interface BarChartProps {
+ values: number[];
+ xLabels: (string | null)[];
+ tooltips: string[];
+ caption?: string;
+}
+
+interface ChartColors {
+ fg: string;
+ muted: string;
+ bg: string;
+}
+
+/** Read a computed color off a throwaway element carrying a Tailwind class, so
+ * we get the real themed token value rather than guessing a CSS variable name. */
+function probeColor(className: string, prop: "color" | "backgroundColor"): string {
+ const tmp = document.createElement("span");
+ tmp.className = className;
+ tmp.style.cssText = "position:fixed;opacity:0;pointer-events:none";
+ document.body.appendChild(tmp);
+ const value = getComputedStyle(tmp)[prop];
+ document.body.removeChild(tmp);
+ return value;
+}
+
+function readChartColors(container: HTMLElement | null): ChartColors {
+ return {
+ fg: getComputedStyle(container ?? document.body).color || "#000",
+ muted: probeColor("text-muted-foreground", "color") || "#888",
+ bg: probeColor("bg-background", "backgroundColor") || "#fff",
+ };
+}
+
+export function BarChart({ values, xLabels, tooltips, caption }: BarChartProps) {
+ const svgRef = useRef(null);
+ const containerRef = useRef(null);
+ const [hover, setHover] = useState(null);
+ const [colors, setColors] = useState({ fg: "#000", muted: "#888", bg: "#fff" });
+
+ // Resolve themed colors on mount, and re-resolve when the theme toggles
+ // (the app flips data-theme on ). Keeping colors in state means the
+ // draw effect below redraws automatically on a live light/dark switch.
+ useEffect(() => {
+ setColors(readChartColors(containerRef.current));
+ const observer = new MutationObserver(() => {
+ setColors(readChartColors(containerRef.current));
+ });
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
+ return () => observer.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const svg = svgRef.current;
+ // Guard: svg may be removed from DOM during unmount before this effect runs
+ if (!svg || !svg.isConnected) return;
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+
+ const n = values.length;
+ if (n === 0) return;
+
+ const fgColor = colors.fg;
+ const mutedColor = colors.muted;
+ const rawMax = Math.max(...values, 0);
+ const ticks = (() => {
+ const nice = niceMax(rawMax);
+ const step = nice / 4;
+ return Array.from({ length: 5 }, (_, i) => i * step);
+ })();
+ const maxTick = ticks[ticks.length - 1];
+ const toY = (v: number) => PT + (1 - v / maxTick) * CH;
+ const gap = 2;
+ const barW = Math.max(2, CW / n - gap);
+ const toX = (i: number) => PL + (i / n) * CW + (CW / n - barW) / 2;
+
+ // Y gridlines + labels
+ for (const tick of ticks) {
+ const y = toY(tick);
+ svg.appendChild(mk("line", {
+ x1: PL, y1: y, x2: PL + CW, y2: y,
+ stroke: fgColor,
+ "stroke-opacity": tick === 0 ? "0.12" : "0.06",
+ "stroke-width": "1",
+ "stroke-dasharray": tick === 0 ? "" : "3 3",
+ }));
+ const label = mk("text", {
+ x: PL - 5, y, "text-anchor": "end", "dominant-baseline": "middle",
+ "font-size": "9", fill: mutedColor,
+ });
+ label.textContent = fmtTick(tick);
+ svg.appendChild(label);
+ }
+
+ // Bars
+ for (let i = 0; i < n; i++) {
+ const bx = toX(i);
+ const barH = Math.max(0, (values[i] / maxTick) * CH);
+ const by = toY(values[i]);
+ const isHovered = hover === i;
+ svg.appendChild(mk("rect", {
+ x: bx, y: by, width: barW, height: barH,
+ rx: Math.min(3, barW / 4),
+ fill: fgColor,
+ "fill-opacity": isHovered ? "0.85" : values[i] > 0 ? "0.5" : "0.1",
+ }));
+ }
+
+ // X labels
+ for (let i = 0; i < n; i++) {
+ const lbl = xLabels[i];
+ if (!lbl) continue;
+ const cx = toX(i) + barW / 2;
+ const t = mk("text", {
+ x: cx, y: PT + CH + 14, "text-anchor": "middle",
+ "font-size": "9", fill: mutedColor,
+ });
+ t.textContent = lbl;
+ svg.appendChild(t);
+ }
+
+ // Hover tooltip
+ if (hover !== null) {
+ const bx = toX(hover);
+ const cx = bx + barW / 2;
+ const hy = toY(values[hover]);
+ const tip = tooltips[hover];
+ const bw = Math.max(tip.length * 5.6 + 16, 60);
+ const bh = 20;
+ const tx = Math.min(Math.max(cx - bw / 2, PL), PL + CW - bw);
+ const ty = Math.max(hy - bh - 8, PT);
+ svg.appendChild(mk("rect", {
+ x: tx, y: ty, width: bw, height: bh, rx: "4",
+ fill: fgColor, "fill-opacity": "0.9",
+ }));
+ const tipText = mk("text", {
+ x: tx + bw / 2, y: ty + bh / 2,
+ "text-anchor": "middle", "dominant-baseline": "middle",
+ "font-size": "9.5", fill: colors.bg,
+ });
+ tipText.textContent = tip;
+ svg.appendChild(tipText);
+ }
+ // Return cleanup: clear SVG children safely on unmount
+ return () => {
+ if (svg.isConnected) {
+ while (svg.firstChild) svg.removeChild(svg.firstChild);
+ }
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [values, xLabels, tooltips, hover, colors]);
+
+ return (
+
+
+ );
+}
diff --git a/app/src/components/usage-parts.tsx b/app/src/components/usage-parts.tsx
new file mode 100644
index 000000000..a8588bb64
--- /dev/null
+++ b/app/src/components/usage-parts.tsx
@@ -0,0 +1,35 @@
+import type { ReactNode } from "react";
+
+export { fmtTokens, fmtCost, shortModel } from "../lib/usage-format";
+
+export function Section({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+
{title}
+ {children}
+
+ );
+}
+
+export function KpiCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
+ return (
+
+
+ {icon}
+ {label}
+
+
{value}
+
+ );
+}
+
+export function DataBar({ pct }: { pct: number }) {
+ return (
+
+ );
+}
diff --git a/app/src/components/usage-view.tsx b/app/src/components/usage-view.tsx
new file mode 100644
index 000000000..c9be2a0d2
--- /dev/null
+++ b/app/src/components/usage-view.tsx
@@ -0,0 +1,202 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { BarChart2, Zap, Layers, DollarSign, Clock, Cpu, ChevronDown, Info } from "lucide-react";
+import { cn } from "@houston-ai/core";
+import { useCostAnalytics, applyFilter } from "../hooks/use-cost-analytics";
+import { aggregate, cacheHitPct } from "../lib/cost-aggregate";
+import { BarChart } from "./usage-chart";
+import { KpiCard, Section, DataBar, fmtTokens, fmtCost, shortModel } from "./usage-parts";
+
+type FilterKind = "all" | "agent" | "model";
+interface Filter { kind: FilterKind; value: string; label: string }
+/** Selection identity, without a frozen label. The label is derived from the
+ * current options so it can't go stale on a language change or agent rename. */
+type Selection = Pick;
+
+export function UsageView() {
+ const { t } = useTranslation("shell");
+ const data = useCostAnalytics();
+ const [selection, setSelection] = useState({ kind: "all", value: "" });
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ const filterOptions = useMemo(() => {
+ const opts: Filter[] = [{ kind: "all", value: "", label: t("usage.filterAll") }];
+ for (const a of data.agents) opts.push({ kind: "agent", value: a.path, label: a.name });
+ for (const m of data.models) opts.push({ kind: "model", value: m, label: shortModel(m) });
+ return opts;
+ }, [data.agents, data.models, t]);
+
+ // A selected agent/model can disappear (deleted, or it had no sessions); fall
+ // back to "all" so the view never shows a dangling filter.
+ const filter = filterOptions.find((o) => o.kind === selection.kind && o.value === selection.value)
+ ?? filterOptions[0];
+
+ const metrics = useMemo(
+ () => filter.kind === "all" ? data : aggregate(applyFilter(data.sessions, filter.kind, filter.value)),
+ [data, filter],
+ );
+
+ if (data.loading) {
+ return (
+
+
+
+
{t("usage.loading")}
+
+
+ );
+ }
+
+ if (data.totalSessions === 0) {
+ return (
+
+
+
+
{t("usage.emptyTitle")}
+
{t("usage.emptySubtitle")}
+
+
+ );
+ }
+
+ const recentDays = metrics.byDay.slice(-14);
+ const useTokensForDay = !metrics.hasCostData;
+ const maxAgentTokens = Math.max(...metrics.byAgent.map((a) => a.totalTokens), 1);
+ const maxModelTokens = Math.max(...metrics.byModel.map((m) => m.totalTokens), 1);
+
+ return (
+
+ {dropdownOpen &&
setDropdownOpen(false)} />}
+
+
+
+
+
{t("usage.title")}
+
{t("usage.subtitle")}
+
+ {filterOptions.length > 1 && (
+
+
+ {dropdownOpen && (
+
+ {filterOptions.map((opt) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {metrics.hasCostData
+ ? } label={t("usage.totalSpend")} value={fmtCost(metrics.totalCost)} />
+ : } label={t("usage.totalTokens")} value={fmtTokens(metrics.totalTokens)} />}
+ } label={t("usage.totalSessions")} value={String(metrics.totalSessions)} />
+ } label={t("usage.cacheEfficiency")} value={`${metrics.cacheEfficiencyPct}%`} />
+ {metrics.hasCostData
+ ? } label={t("usage.avgCostPerSession")} value={metrics.totalSessions > 0 ? fmtCost(metrics.totalCost / metrics.totalSessions) : "$0.00"} />
+ : } label={t("usage.cachedTokens")} value={fmtTokens(metrics.cachedTokens)} />}
+
+
+ {metrics.hasCostData && (
+
+ } label={t("usage.totalTokens")} value={fmtTokens(metrics.totalTokens)} />
+ } label={t("usage.cachedTokens")} value={`${fmtTokens(metrics.cachedTokens)} (${metrics.cacheEfficiencyPct}%)`} />
+
+ )}
+
+
+
+ {t("usage.dataNote")}
+
+
+ {metrics.byModel.length > 0 && (
+
+
+ {metrics.byModel.map((m) => (
+
+
+
{m.model}
+
+ {t("usage.sessions", { count: m.sessionCount })}
+ {fmtTokens(m.totalTokens)} {t("usage.tokens")}
+ {m.hasCost && m.totalCost > 0 && {fmtCost(m.totalCost)}}
+
+
+
+
+ ))}
+
+
+ )}
+
+ {metrics.byAgent.length > 1 && (
+
+
+ {metrics.byAgent.map((agent) => (
+
+
+
{agent.agentName}
+
+ {t("usage.sessions", { count: agent.sessionCount })}
+ {fmtTokens(agent.totalTokens)} {t("usage.tokens")}
+ {agent.hasCost && agent.totalCost > 0 && {fmtCost(agent.totalCost)}}
+
+
+
+ {agent.contextTokens > 0 && (
+
{t("usage.cacheHit", { pct: cacheHitPct(agent.cachedTokens, agent.contextTokens) })}
+ )}
+
+ ))}
+
+
+ )}
+
+ {recentDays.length > 1 && (
+
+ useTokensForDay ? d.tokens : d.cost)}
+ xLabels={recentDays.map((d, i) => i === 0 || i === recentDays.length - 1 || i % 3 === 0 ? d.date.slice(5) : null)}
+ tooltips={recentDays.map((d) => useTokensForDay ? `${d.date.slice(5)}: ${fmtTokens(d.tokens)} ${t("usage.tokens")}` : `${d.date.slice(5)}: ${fmtCost(d.cost)}`)}
+ caption={useTokensForDay ? t("usage.captionByDay") : t("usage.captionByDayCost")}
+ />
+
+ )}
+
+ {metrics.byHour.some((h) => h.tokens > 0) && (
+
+ h.tokens)}
+ xLabels={metrics.byHour.map((h) => h.hour % 6 === 0 ? `${String(h.hour).padStart(2, "0")}h` : null)}
+ tooltips={metrics.byHour.map((h) => { const l = `${String(h.hour).padStart(2, "0")}h`; return h.tokens > 0 ? `${l}: ${fmtTokens(h.tokens)} (${h.sessions}s)` : l; })}
+ caption={t("usage.captionByHour")}
+ />
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/hooks/use-agent-invalidation.ts b/app/src/hooks/use-agent-invalidation.ts
index d02ec5045..3f60209c4 100644
--- a/app/src/hooks/use-agent-invalidation.ts
+++ b/app/src/hooks/use-agent-invalidation.ts
@@ -22,6 +22,7 @@ export function useAgentInvalidation() {
useSessionStatusStore.getState().clearAll();
qc.invalidateQueries({ queryKey: ["activity"] });
qc.invalidateQueries({ queryKey: ["all-conversations"] });
+ qc.invalidateQueries({ queryKey: ["cost-analytics"] });
});
const unlisten = subscribeHoustonEvents((p: HoustonEvent) => {
console.log("[invalidation] event:", p.type, "data" in p ? (p as { data: { agent_path?: string } }).data?.agent_path : "");
@@ -56,11 +57,13 @@ export function useAgentInvalidation() {
case "LearningsChanged":
qc.invalidateQueries({ queryKey: queryKeys.learnings(p.data.agent_path) });
break;
- // SessionStatus triggers activity invalidation (agent finished → status changed)
+ // SessionStatus triggers activity invalidation (agent finished → status changed).
+ // A finished session also wrote a new final_result, so refresh cost analytics.
case "SessionStatus":
if (p.data.status === "completed" || p.data.status === "error") {
qc.invalidateQueries({ queryKey: ["activity"] });
qc.invalidateQueries({ queryKey: ["all-conversations"] });
+ qc.invalidateQueries({ queryKey: ["cost-analytics"] });
}
break;
// Composio CLI became available — refresh integrations state.
diff --git a/app/src/hooks/use-cost-analytics.ts b/app/src/hooks/use-cost-analytics.ts
new file mode 100644
index 000000000..b65efec68
--- /dev/null
+++ b/app/src/hooks/use-cost-analytics.ts
@@ -0,0 +1,97 @@
+import { useMemo } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { useAgentStore } from "../stores/agents";
+import { queryKeys } from "../lib/query-keys";
+import { tauriConversations } from "../lib/tauri";
+import { getEngine } from "../lib/engine";
+import { isoToLocalDate } from "../lib/date-utils";
+import { calcTokenCost } from "../lib/token-pricing";
+import { aggregate, foldFinals, type AggregatedMetrics, type SessionResult, type FinalResultRow } from "../lib/cost-aggregate";
+
+export type { SessionResult, AggregatedMetrics, AgentMetrics, ModelMetrics, DailyMetrics, HourlyMetrics } from "../lib/cost-aggregate";
+export { applyFilter } from "../lib/cost-aggregate";
+
+export interface CostAnalytics extends AggregatedMetrics {
+ sessions: SessionResult[];
+ models: string[];
+ agents: { name: string; path: string }[];
+ loading: boolean;
+}
+
+const CONCURRENCY = 6;
+
+async function limitedAll
(tasks: (() => Promise)[], limit: number): Promise {
+ const results: T[] = [];
+ for (let i = 0; i < tasks.length; i += limit) {
+ results.push(...await Promise.all(tasks.slice(i, i + limit).map((fn) => fn())));
+ }
+ return results;
+}
+
+/**
+ * Read every conversation's final-result rows and fold them into one
+ * SessionResult per conversation. The top-level conversation list goes through
+ * the toast-on-error tauri wrapper; individual history reads use the engine
+ * directly and skip on failure so one unreadable conversation can't blank the
+ * whole dashboard.
+ */
+async function loadSessions(agentPaths: string[]): Promise {
+ const conversations = await tauriConversations.listAll(agentPaths);
+ const engine = getEngine();
+ const sessions: SessionResult[] = [];
+
+ await limitedAll(
+ conversations.map((conv) => async () => {
+ try {
+ const feed = await engine.loadChatHistory(conv.agent_path, conv.session_key);
+ const finals = feed
+ .filter((f) => f.feed_type === "final_result")
+ .map((f) => f.data as FinalResultRow);
+ if (!finals.length) return;
+
+ sessions.push({
+ agentName: conv.agent_name,
+ agentPath: conv.agent_path,
+ date: conv.updated_at ? isoToLocalDate(conv.updated_at) : "",
+ hour: conv.updated_at ? new Date(conv.updated_at).getHours() : -1,
+ ...foldFinals(finals, calcTokenCost),
+ });
+ } catch {
+ // Skip a single unreadable conversation; the rest still aggregate.
+ }
+ }),
+ CONCURRENCY,
+ );
+
+ return sessions;
+}
+
+export function useCostAnalytics(): CostAnalytics {
+ const agents = useAgentStore((s) => s.agents);
+ const agentPaths = agents.map((a) => a.folderPath);
+
+ const query = useQuery({
+ queryKey: queryKeys.costAnalytics(agentPaths),
+ queryFn: () => loadSessions(agentPaths),
+ enabled: agents.length > 0,
+ });
+
+ const sessions = useMemo(() => query.data ?? [], [query.data]);
+ const metrics = useMemo(() => aggregate(sessions), [sessions]);
+ const models = useMemo(
+ () => [...new Set(sessions.map((s) => s.model).filter(Boolean))],
+ [sessions],
+ );
+ const agentList = useMemo(
+ () => agents.map((a) => ({ name: a.name, path: a.folderPath })),
+ [agents],
+ );
+
+ return {
+ ...metrics,
+ sessions,
+ models,
+ agents: agentList,
+ loading: agents.length > 0 && query.isPending,
+ };
+}
diff --git a/app/src/lib/cost-aggregate.ts b/app/src/lib/cost-aggregate.ts
new file mode 100644
index 000000000..43526082b
--- /dev/null
+++ b/app/src/lib/cost-aggregate.ts
@@ -0,0 +1,222 @@
+/** A persisted `final_result` feed row's data payload (one provider turn). */
+export interface FinalResultRow {
+ cost_usd?: number | null;
+ model?: string | null;
+ usage?: { context_tokens: number; output_tokens: number; cached_tokens: number } | null;
+}
+
+/** Cost and token totals folded from one conversation's final-result turns. */
+export interface FoldedUsage {
+ model: string;
+ cost: number;
+ hasCost: boolean;
+ totalTokens: number;
+ contextTokens: number;
+ cachedTokens: number;
+}
+
+/** Estimate a turn's cost from its model and token counts, or null if unpriced. */
+export type CostEstimator = (
+ model: string,
+ contextTokens: number,
+ outputTokens: number,
+ cachedTokens: number,
+) => number | null;
+
+/**
+ * Fold a conversation's final-result turns into a single usage record.
+ *
+ * Per turn: use the CLI's `cost_usd` when present (exact), otherwise estimate
+ * from the model + token usage via `estimate`. Tokens are summed across turns
+ * (each turn is a separately billed request). `model` is the last turn's model,
+ * used to label the conversation in the by-model breakdown.
+ *
+ * The estimator is injected so this stays free of pricing-table imports.
+ */
+export function foldFinals(finals: FinalResultRow[], estimate: CostEstimator): FoldedUsage {
+ let model = "", cost = 0, hasCost = false;
+ let totalTokens = 0, contextTokens = 0, cachedTokens = 0;
+
+ for (const d of finals) {
+ if (d.model) model = d.model;
+ if (d.cost_usd != null) {
+ cost += d.cost_usd;
+ hasCost = true;
+ } else if (d.model && d.usage) {
+ const estimated = estimate(d.model, d.usage.context_tokens, d.usage.output_tokens, d.usage.cached_tokens);
+ if (estimated != null) {
+ cost += estimated;
+ hasCost = true;
+ }
+ }
+ if (d.usage) {
+ totalTokens += d.usage.context_tokens + d.usage.output_tokens;
+ contextTokens += d.usage.context_tokens;
+ cachedTokens += d.usage.cached_tokens;
+ }
+ }
+
+ return { model, cost, hasCost, totalTokens, contextTokens, cachedTokens };
+}
+
+export interface SessionResult {
+ agentName: string;
+ agentPath: string;
+ model: string;
+ cost: number;
+ hasCost: boolean;
+ /** All tokens billed this session (input + output). */
+ totalTokens: number;
+ /** Input (context) tokens only, the denominator for cache hit rate. */
+ contextTokens: number;
+ /** Cache-read tokens (a subset of contextTokens). */
+ cachedTokens: number;
+ date: string;
+ hour: number;
+}
+
+export interface AgentMetrics {
+ agentName: string;
+ agentPath: string;
+ totalCost: number;
+ hasCost: boolean;
+ sessionCount: number;
+ totalTokens: number;
+ contextTokens: number;
+ cachedTokens: number;
+}
+
+export interface ModelMetrics {
+ model: string;
+ sessionCount: number;
+ totalTokens: number;
+ contextTokens: number;
+ cachedTokens: number;
+ totalCost: number;
+ hasCost: boolean;
+}
+
+/** Cache hit rate as a 0-100 percent: cache reads over total input tokens. */
+export function cacheHitPct(cachedTokens: number, contextTokens: number): number {
+ return contextTokens > 0 ? Math.round((cachedTokens / contextTokens) * 100) : 0;
+}
+
+export interface DailyMetrics {
+ date: string;
+ cost: number;
+ tokens: number;
+}
+
+export interface HourlyMetrics {
+ hour: number;
+ tokens: number;
+ sessions: number;
+}
+
+export interface AggregatedMetrics {
+ totalCost: number;
+ totalSessions: number;
+ totalTokens: number;
+ contextTokens: number;
+ cachedTokens: number;
+ cacheEfficiencyPct: number;
+ hasCostData: boolean;
+ byAgent: AgentMetrics[];
+ byModel: ModelMetrics[];
+ byDay: DailyMetrics[];
+ byHour: HourlyMetrics[];
+}
+
+export function aggregate(sessions: SessionResult[]): AggregatedMetrics {
+ const agentMap = new Map();
+ const modelMap = new Map();
+ const dayMap = new Map();
+ const hourMap = new Map();
+ let totalCost = 0, totalTokens = 0, contextTokens = 0, cachedTokens = 0;
+ let hasCostData = false;
+
+ for (const s of sessions) {
+ totalCost += s.cost;
+ totalTokens += s.totalTokens;
+ contextTokens += s.contextTokens;
+ cachedTokens += s.cachedTokens;
+ if (s.hasCost) hasCostData = true;
+
+ const ag = agentMap.get(s.agentPath);
+ if (ag) {
+ ag.totalCost += s.cost;
+ ag.sessionCount += 1;
+ ag.totalTokens += s.totalTokens;
+ ag.contextTokens += s.contextTokens;
+ ag.cachedTokens += s.cachedTokens;
+ if (s.hasCost) ag.hasCost = true;
+ } else {
+ agentMap.set(s.agentPath, {
+ agentName: s.agentName, agentPath: s.agentPath,
+ totalCost: s.cost, hasCost: s.hasCost, sessionCount: 1,
+ totalTokens: s.totalTokens, contextTokens: s.contextTokens, cachedTokens: s.cachedTokens,
+ });
+ }
+
+ if (s.model) {
+ const md = modelMap.get(s.model);
+ if (md) {
+ md.sessionCount += 1;
+ md.totalTokens += s.totalTokens;
+ md.contextTokens += s.contextTokens;
+ md.cachedTokens += s.cachedTokens;
+ md.totalCost += s.cost;
+ if (s.hasCost) md.hasCost = true;
+ } else {
+ modelMap.set(s.model, {
+ model: s.model, sessionCount: 1, totalTokens: s.totalTokens,
+ contextTokens: s.contextTokens, cachedTokens: s.cachedTokens,
+ totalCost: s.cost, hasCost: s.hasCost,
+ });
+ }
+ }
+
+ if (s.date) {
+ const d = dayMap.get(s.date) ?? { cost: 0, tokens: 0 };
+ d.cost += s.cost;
+ d.tokens += s.totalTokens;
+ dayMap.set(s.date, d);
+ }
+
+ if (s.hour >= 0) {
+ const h = hourMap.get(s.hour) ?? { tokens: 0, sessions: 0 };
+ h.tokens += s.totalTokens;
+ h.sessions += 1;
+ hourMap.set(s.hour, h);
+ }
+ }
+
+ const byDay = [...dayMap.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([date, v]) => ({ date, ...v }));
+
+ const byHour = Array.from({ length: 24 }, (_, i) => ({
+ hour: i,
+ tokens: hourMap.get(i)?.tokens ?? 0,
+ sessions: hourMap.get(i)?.sessions ?? 0,
+ }));
+
+ const byAgent = [...agentMap.values()].sort((a, b) => b.totalTokens - a.totalTokens);
+ const byModel = [...modelMap.values()].sort((a, b) => b.totalTokens - a.totalTokens);
+
+ return {
+ totalCost, totalSessions: sessions.length, totalTokens, contextTokens, cachedTokens,
+ cacheEfficiencyPct: cacheHitPct(cachedTokens, contextTokens),
+ hasCostData, byAgent, byModel, byDay, byHour,
+ };
+}
+
+export function applyFilter(
+ sessions: SessionResult[],
+ kind: "all" | "agent" | "model",
+ value: string,
+): SessionResult[] {
+ if (kind === "all") return sessions;
+ if (kind === "agent") return sessions.filter((s) => s.agentPath === value);
+ return sessions.filter((s) => s.model === value);
+}
diff --git a/app/src/lib/date-utils.ts b/app/src/lib/date-utils.ts
new file mode 100644
index 000000000..60a49b25b
--- /dev/null
+++ b/app/src/lib/date-utils.ts
@@ -0,0 +1,5 @@
+/** Convert a UTC ISO string to a local calendar date "YYYY-MM-DD". */
+export function isoToLocalDate(iso: string): string {
+ const d = new Date(iso);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+}
diff --git a/app/src/lib/query-keys.ts b/app/src/lib/query-keys.ts
index 5d8e3f3ca..47b301fd2 100644
--- a/app/src/lib/query-keys.ts
+++ b/app/src/lib/query-keys.ts
@@ -25,6 +25,8 @@ export const queryKeys = {
["conversations", agentPath] as const,
allConversations: (agentPaths: string[]) =>
["all-conversations", ...agentPaths] as const,
+ costAnalytics: (agentPaths: string[]) =>
+ ["cost-analytics", ...agentPaths] as const,
chatHistory: (agentPath: string, sessionKey: string) =>
["chat-history", agentPath, sessionKey] as const,
diff --git a/app/src/lib/token-pricing.ts b/app/src/lib/token-pricing.ts
new file mode 100644
index 000000000..cedcf94e4
--- /dev/null
+++ b/app/src/lib/token-pricing.ts
@@ -0,0 +1,81 @@
+/**
+ * Token pricing used to estimate session cost in USD.
+ *
+ * When the provider CLI reports an exact `cost_usd` (Claude on API-key billing),
+ * that value is used directly and this table is never consulted. When it does
+ * not (Claude on a subscription, or Codex, which never report cost), cost is
+ * estimated here from token usage at each provider's published API rates. For
+ * subscription users this is an API-equivalent estimate of value, not a literal
+ * charge.
+ *
+ * Only models Houston's picker can select are priced (see `providers.ts`). To
+ * support a new model, add it to the picker AND add its row here.
+ *
+ * Prices in USD per million tokens. Sources (verified June 2026):
+ * Anthropic: platform.claude.com/docs/en/docs/about-claude/pricing
+ * OpenAI: developers.openai.com/api/docs/pricing
+ *
+ * Cache-write caveat: the persisted TokenUsage folds cache-creation (write)
+ * tokens into `context_tokens` without separating them, so this estimate prices
+ * them at the base input rate rather than the 1.25x cache-write rate. The
+ * resulting estimate is therefore a slight under-count on cache-heavy turns.
+ * It is acceptable for an estimate, and exact figures come from `cost_usd` when
+ * the CLI provides them.
+ */
+interface ModelPricing {
+ /** Cost per million fresh input tokens. */
+ input: number;
+ /** Cost per million output tokens. */
+ output: number;
+ /** Cost per million cache-read tokens. */
+ cacheRead: number;
+}
+
+const PRICING: Record = {
+ // Anthropic
+ "claude-opus-4-8": { input: 5.0, output: 25.0, cacheRead: 0.5 },
+ "claude-opus-4-7": { input: 5.0, output: 25.0, cacheRead: 0.5 },
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0, cacheRead: 0.3 },
+ // OpenAI (Codex)
+ "gpt-5.5": { input: 5.0, output: 30.0, cacheRead: 0.5 },
+};
+
+/**
+ * Legacy shorthand model ids that older agent configs may still hold, mapped to
+ * the catalog id they denote. Mirrors `LEGACY_MODEL_ALIASES` in providers.ts.
+ */
+const MODEL_ALIASES: Record = {
+ opus: "claude-opus-4-7",
+ sonnet: "claude-sonnet-4-6",
+};
+
+function getPricing(model: string): ModelPricing | null {
+ const resolved = MODEL_ALIASES[model] ?? model;
+ if (PRICING[resolved]) return PRICING[resolved];
+ // Prefix fall-back so a date- or variant-suffixed id still resolves to its
+ // base model's rate (e.g. "claude-sonnet-4-6-20260101", "gpt-5.5-2026...").
+ for (const [key, price] of Object.entries(PRICING)) {
+ if (resolved.startsWith(key)) return price;
+ }
+ return null;
+}
+
+/**
+ * Estimate cost in USD from token counts.
+ * Returns null when the model is unpriced, so the caller shows token usage
+ * instead of a cost.
+ *
+ * `cachedTokens` are billed at the cheaper cacheRead rate; the remaining
+ * `contextTokens - cachedTokens` are billed at the full input rate.
+ */
+export function calcTokenCost(
+ model: string,
+ contextTokens: number,
+ outputTokens: number,
+ cachedTokens: number,
+): number | null {
+ const p = getPricing(model);
+ if (!p) return null;
+ const fresh = Math.max(0, contextTokens - cachedTokens);
+ return (fresh * p.input + cachedTokens * p.cacheRead + outputTokens * p.output) / 1_000_000;
+}
diff --git a/app/src/lib/usage-format.ts b/app/src/lib/usage-format.ts
new file mode 100644
index 000000000..defb41666
--- /dev/null
+++ b/app/src/lib/usage-format.ts
@@ -0,0 +1,33 @@
+export function fmtTokens(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
+ return String(n);
+}
+
+export function fmtCost(n: number): string {
+ if (n === 0) return "$0.00";
+ if (n >= 1) return `$${n.toFixed(2)}`;
+ if (n >= 0.01) return `$${n.toFixed(4)}`;
+ return `$${n.toFixed(6)}`;
+}
+
+/**
+ * Human-readable model label for the filter dropdown.
+ *
+ * Handles the ids Houston's picker produces (providers.ts): Claude full ids,
+ * the OpenAI `gpt-5.5` id, and the legacy `sonnet`/`opus` shorthands that older
+ * configs may still hold. Anything else is title-cased as a safe fallback.
+ */
+export function shortModel(model: string): string {
+ // Claude full id: "claude-sonnet-4-6" → "Sonnet 4.6"
+ const claude = model.match(/^claude-([a-z]+)-(\d[\d-]*)$/i);
+ if (claude) {
+ const name = claude[1].charAt(0).toUpperCase() + claude[1].slice(1);
+ return `${name} ${claude[2].replace(/-/g, ".")}`;
+ }
+ // OpenAI version id: "gpt-5.5" → "GPT-5.5"
+ const gpt = model.match(/^gpt-(\d[\d.]+)$/i);
+ if (gpt) return `GPT-${gpt[1]}`;
+ // Legacy shorthand ("sonnet", "opus"): capitalise first letter.
+ return model.charAt(0).toUpperCase() + model.slice(1);
+}
diff --git a/app/src/locales/en/shell.json b/app/src/locales/en/shell.json
index a8cdb466c..f89044e81 100644
--- a/app/src/locales/en/shell.json
+++ b/app/src/locales/en/shell.json
@@ -21,7 +21,8 @@
"needsYouCount_one": "{{count}} issue needs you",
"needsYouCount_other": "{{count}} issues need you",
"runningCount_one": "{{count}} issue running",
- "runningCount_other": "{{count}} issues running"
+ "runningCount_other": "{{count}} issues running",
+ "usage": "Usage"
},
"tabActions": {
"newMission": "New mission",
@@ -396,5 +397,34 @@
"successWithId": "Mission control is on it. Reference: {{id}}",
"successNoId": "Mission control is on it.",
"errorTitle": "Couldn't send report"
+ },
+ "usage": {
+ "title": "Usage",
+ "subtitle": "Cost and token consumption across your agents",
+ "loading": "Loading usage data...",
+ "emptyTitle": "No sessions yet",
+ "emptySubtitle": "Start a conversation with any agent to see usage here.",
+ "totalSpend": "Total spend",
+ "totalSessions": "Sessions",
+ "totalTokens": "Tokens",
+ "cachedTokens": "Cached",
+ "cacheEfficiency": "Cache hit",
+ "avgCostPerSession": "Avg / session",
+ "filterAll": "All agents",
+ "filterAgent": "Agent",
+ "filterModel": "Model",
+ "byAgent": "By agent",
+ "byModel": "By model",
+ "byHour": "Activity by hour",
+ "dailyTrend": "Daily trend",
+ "tokens": "tokens",
+ "cacheHit": "{{pct}}% cache hit",
+ "captionByDay": "Tokens per day (last 14 days)",
+ "captionByDayCost": "Cost per day (last 14 days)",
+ "captionByHour": "Tokens by local hour",
+ "dataNote": "Cost is exact when Claude reports it, otherwise estimated from official published pricing. Gemini shows tokens only.",
+ "loadError": "Failed to load usage data",
+ "sessions_one": "session",
+ "sessions_other": "sessions"
}
}
diff --git a/app/src/locales/es/shell.json b/app/src/locales/es/shell.json
index 1f97f3e64..dcc618902 100644
--- a/app/src/locales/es/shell.json
+++ b/app/src/locales/es/shell.json
@@ -21,7 +21,8 @@
"needsYouCount_one": "{{count}} asunto necesita tu atención",
"needsYouCount_other": "{{count}} asuntos necesitan tu atención",
"runningCount_one": "{{count}} asunto en curso",
- "runningCount_other": "{{count}} asuntos en curso"
+ "runningCount_other": "{{count}} asuntos en curso",
+ "usage": "Uso"
},
"tabActions": {
"newMission": "Nueva misión",
@@ -396,5 +397,34 @@
"successWithId": "El centro de control está en eso. Referencia: {{id}}",
"successNoId": "El centro de control está en eso.",
"errorTitle": "No se pudo enviar el reporte"
+ },
+ "usage": {
+ "title": "Uso",
+ "subtitle": "Costo y consumo de tokens en todos tus agentes",
+ "loading": "Cargando datos de uso...",
+ "emptyTitle": "Sin sesiones aún",
+ "emptySubtitle": "Inicia una conversación con cualquier agente para ver el uso aquí.",
+ "totalSpend": "Gasto total",
+ "totalSessions": "Sesiones",
+ "totalTokens": "Tokens",
+ "cachedTokens": "En caché",
+ "cacheEfficiency": "Aciertos de caché",
+ "avgCostPerSession": "Promedio / sesión",
+ "filterAll": "Todos los agentes",
+ "filterAgent": "Agente",
+ "filterModel": "Modelo",
+ "byAgent": "Por agente",
+ "byModel": "Por modelo",
+ "byHour": "Actividad por hora",
+ "dailyTrend": "Tendencia diaria",
+ "tokens": "tokens",
+ "cacheHit": "{{pct}}% en caché",
+ "captionByDay": "Tokens por día (últimos 14 días)",
+ "captionByDayCost": "Costo por día (últimos 14 días)",
+ "captionByHour": "Tokens por hora local",
+ "dataNote": "El costo es exacto cuando Claude lo reporta; si no, se estima con precios oficiales publicados. Gemini muestra solo tokens.",
+ "loadError": "Error al cargar datos de uso",
+ "sessions_one": "sesión",
+ "sessions_other": "sesiones"
}
}
diff --git a/app/src/locales/pt/shell.json b/app/src/locales/pt/shell.json
index adcde0719..cee4a5375 100644
--- a/app/src/locales/pt/shell.json
+++ b/app/src/locales/pt/shell.json
@@ -21,7 +21,8 @@
"needsYouCount_one": "{{count}} assunto precisa de você",
"needsYouCount_other": "{{count}} assuntos precisam de você",
"runningCount_one": "{{count}} assunto em execução",
- "runningCount_other": "{{count}} assuntos em execução"
+ "runningCount_other": "{{count}} assuntos em execução",
+ "usage": "Uso"
},
"tabActions": {
"newMission": "Nova missão",
@@ -396,5 +397,34 @@
"successWithId": "O centro de controle está cuidando disso. Referência: {{id}}",
"successNoId": "O centro de controle está cuidando disso.",
"errorTitle": "Não foi possível enviar o relatório"
+ },
+ "usage": {
+ "title": "Uso",
+ "subtitle": "Custo e consumo de tokens em todos os seus agentes",
+ "loading": "Carregando dados de uso...",
+ "emptyTitle": "Sem sessões ainda",
+ "emptySubtitle": "Inicie uma conversa com qualquer agente para ver o uso aqui.",
+ "totalSpend": "Gasto total",
+ "totalSessions": "Sessões",
+ "totalTokens": "Tokens",
+ "cachedTokens": "Em cache",
+ "cacheEfficiency": "Acertos de cache",
+ "avgCostPerSession": "Média / sessão",
+ "filterAll": "Todos os agentes",
+ "filterAgent": "Agente",
+ "filterModel": "Modelo",
+ "byAgent": "Por agente",
+ "byModel": "Por modelo",
+ "byHour": "Atividade por hora",
+ "dailyTrend": "Tendência diária",
+ "tokens": "tokens",
+ "cacheHit": "{{pct}}% em cache",
+ "captionByDay": "Tokens por dia (últimos 14 dias)",
+ "captionByDayCost": "Custo por dia (últimos 14 dias)",
+ "captionByHour": "Tokens por hora local",
+ "dataNote": "O custo é exato quando o Claude o informa; caso contrário, é estimado com preços oficiais publicados. O Gemini mostra apenas tokens.",
+ "loadError": "Erro ao carregar dados de uso",
+ "sessions_one": "sessão",
+ "sessions_other": "sessões"
}
}
diff --git a/app/tests/cost-aggregate.test.ts b/app/tests/cost-aggregate.test.ts
new file mode 100644
index 000000000..a9e1e5087
--- /dev/null
+++ b/app/tests/cost-aggregate.test.ts
@@ -0,0 +1,230 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { aggregate, applyFilter, cacheHitPct, foldFinals, type SessionResult, type FinalResultRow } from "../src/lib/cost-aggregate.ts";
+import { calcTokenCost } from "../src/lib/token-pricing.ts";
+
+function session(overrides: Partial = {}): SessionResult {
+ return {
+ agentName: "Test Agent",
+ agentPath: "/agents/test",
+ model: "claude-sonnet-4-6",
+ cost: 0,
+ hasCost: false,
+ totalTokens: 0,
+ contextTokens: 0,
+ cachedTokens: 0,
+ date: "2026-06-20",
+ hour: 10,
+ ...overrides,
+ };
+}
+
+describe("aggregate", () => {
+ it("returns zero totals for empty sessions", () => {
+ const m = aggregate([]);
+ assert.equal(m.totalCost, 0);
+ assert.equal(m.totalSessions, 0);
+ assert.equal(m.totalTokens, 0);
+ assert.equal(m.hasCostData, false);
+ assert.deepEqual(m.byAgent, []);
+ assert.deepEqual(m.byModel, []);
+ });
+
+ it("sums cost and tokens across sessions", () => {
+ const sessions = [
+ session({ cost: 0.01, hasCost: true, totalTokens: 1000, cachedTokens: 800 }),
+ session({ cost: 0.02, hasCost: true, totalTokens: 2000, cachedTokens: 500 }),
+ ];
+ const m = aggregate(sessions);
+ assert.ok(Math.abs(m.totalCost - 0.03) < 0.000001);
+ assert.equal(m.totalSessions, 2);
+ assert.equal(m.totalTokens, 3000);
+ assert.equal(m.cachedTokens, 1300);
+ assert.equal(m.hasCostData, true);
+ });
+
+ it("computes cache hit rate over context (input) tokens, not total tokens", () => {
+ // 10K context, 8K cached, 5K output. Cache hit must be 8K/10K = 80%,
+ // NOT 8K/15K = 53% — output tokens must not dilute the input cache rate.
+ const sessions = [
+ session({ totalTokens: 15_000, contextTokens: 10_000, cachedTokens: 8_000 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.cacheEfficiencyPct, 80);
+ });
+
+ it("cacheHitPct returns 0 when there are no context tokens", () => {
+ assert.equal(cacheHitPct(0, 0), 0);
+ });
+
+ it("groups sessions by agent path", () => {
+ const sessions = [
+ session({ agentPath: "/agents/a", agentName: "A", totalTokens: 100 }),
+ session({ agentPath: "/agents/a", agentName: "A", totalTokens: 200 }),
+ session({ agentPath: "/agents/b", agentName: "B", totalTokens: 50 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.byAgent.length, 2);
+ const a = m.byAgent.find((x) => x.agentPath === "/agents/a")!;
+ assert.equal(a.sessionCount, 2);
+ assert.equal(a.totalTokens, 300);
+ // sorted by totalTokens descending — A first
+ assert.equal(m.byAgent[0].agentPath, "/agents/a");
+ });
+
+ it("groups sessions by model", () => {
+ const sessions = [
+ session({ model: "claude-sonnet-4-6", totalTokens: 500 }),
+ session({ model: "claude-sonnet-4-6", totalTokens: 300 }),
+ session({ model: "gpt-5.3-codex", totalTokens: 100 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.byModel.length, 2);
+ assert.equal(m.byModel[0].model, "claude-sonnet-4-6");
+ assert.equal(m.byModel[0].totalTokens, 800);
+ assert.equal(m.byModel[1].model, "gpt-5.3-codex");
+ });
+
+ it("skips sessions with empty model in byModel", () => {
+ const sessions = [
+ session({ model: "", totalTokens: 100 }),
+ session({ model: "claude-sonnet-4-6", totalTokens: 200 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.byModel.length, 1);
+ });
+
+ it("groups by day correctly", () => {
+ const sessions = [
+ session({ date: "2026-06-18", totalTokens: 100 }),
+ session({ date: "2026-06-19", totalTokens: 200 }),
+ session({ date: "2026-06-18", totalTokens: 50 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.byDay.length, 2);
+ assert.equal(m.byDay[0].date, "2026-06-18");
+ assert.equal(m.byDay[0].tokens, 150);
+ assert.equal(m.byDay[1].date, "2026-06-19");
+ assert.equal(m.byDay[1].tokens, 200);
+ });
+
+ it("generates 24 hourly buckets", () => {
+ const sessions = [session({ hour: 9, totalTokens: 500 })];
+ const m = aggregate(sessions);
+ assert.equal(m.byHour.length, 24);
+ assert.equal(m.byHour[9].tokens, 500);
+ assert.equal(m.byHour[9].sessions, 1);
+ assert.equal(m.byHour[0].tokens, 0);
+ });
+
+ it("hasCostData is false when no session has cost", () => {
+ const m = aggregate([session({ hasCost: false, cost: 0 })]);
+ assert.equal(m.hasCostData, false);
+ });
+
+ it("hasCostData is true when any session has cost", () => {
+ const sessions = [
+ session({ hasCost: false }),
+ session({ hasCost: true, cost: 0.01 }),
+ ];
+ const m = aggregate(sessions);
+ assert.equal(m.hasCostData, true);
+ });
+});
+
+describe("applyFilter", () => {
+ const sessions = [
+ session({ agentPath: "/agents/a", model: "claude-sonnet-4-6" }),
+ session({ agentPath: "/agents/a", model: "gpt-5.3-codex" }),
+ session({ agentPath: "/agents/b", model: "claude-sonnet-4-6" }),
+ ];
+
+ it("'all' returns all sessions", () => {
+ assert.equal(applyFilter(sessions, "all", "").length, 3);
+ });
+
+ it("'agent' filters by agentPath", () => {
+ const filtered = applyFilter(sessions, "agent", "/agents/a");
+ assert.equal(filtered.length, 2);
+ assert.ok(filtered.every((s) => s.agentPath === "/agents/a"));
+ });
+
+ it("'model' filters by model name", () => {
+ const filtered = applyFilter(sessions, "model", "gpt-5.3-codex");
+ assert.equal(filtered.length, 1);
+ assert.equal(filtered[0].agentPath, "/agents/a");
+ });
+
+ it("returns empty array when no sessions match", () => {
+ assert.equal(applyFilter(sessions, "model", "gemini-2.5-pro").length, 0);
+ });
+});
+
+describe("foldFinals", () => {
+ it("returns zeros for no turns", () => {
+ const r = foldFinals([], calcTokenCost);
+ assert.deepEqual(r, { model: "", cost: 0, hasCost: false, totalTokens: 0, contextTokens: 0, cachedTokens: 0 });
+ });
+
+ it("uses the CLI cost_usd when present (exact)", () => {
+ const finals: FinalResultRow[] = [
+ { cost_usd: 0.42, model: "claude-sonnet-4-6", usage: { context_tokens: 1000, output_tokens: 100, cached_tokens: 0 } },
+ ];
+ const r = foldFinals(finals, calcTokenCost);
+ assert.equal(r.cost, 0.42);
+ assert.equal(r.hasCost, true);
+ assert.equal(r.totalTokens, 1100);
+ assert.equal(r.contextTokens, 1000);
+ });
+
+ it("estimates from tokens when cost_usd is absent", () => {
+ // Sonnet: 100K input + 10K output, no cache = (100000*3 + 10000*15)/1e6 = 0.45
+ const finals: FinalResultRow[] = [
+ { cost_usd: null, model: "claude-sonnet-4-6", usage: { context_tokens: 100_000, output_tokens: 10_000, cached_tokens: 0 } },
+ ];
+ const r = foldFinals(finals, calcTokenCost);
+ assert.ok(Math.abs(r.cost - 0.45) < 1e-9);
+ assert.equal(r.hasCost, true);
+ });
+
+ it("tracks tokens but no cost when the model is unpriced", () => {
+ const finals: FinalResultRow[] = [
+ { cost_usd: null, model: "gemini-2.5-pro", usage: { context_tokens: 5000, output_tokens: 500, cached_tokens: 0 } },
+ ];
+ const r = foldFinals(finals, calcTokenCost);
+ assert.equal(r.cost, 0);
+ assert.equal(r.hasCost, false);
+ assert.equal(r.totalTokens, 5500);
+ assert.equal(r.contextTokens, 5000);
+ });
+
+ it("sums tokens and cost across multiple turns", () => {
+ const finals: FinalResultRow[] = [
+ { cost_usd: 0.10, model: "claude-sonnet-4-6", usage: { context_tokens: 1000, output_tokens: 200, cached_tokens: 500 } },
+ { cost_usd: 0.20, model: "claude-sonnet-4-6", usage: { context_tokens: 2000, output_tokens: 300, cached_tokens: 800 } },
+ ];
+ const r = foldFinals(finals, calcTokenCost);
+ assert.ok(Math.abs(r.cost - 0.30) < 1e-9);
+ assert.equal(r.totalTokens, 3500);
+ assert.equal(r.contextTokens, 3000);
+ assert.equal(r.cachedTokens, 1300);
+ });
+
+ it("handles turns with no usage block", () => {
+ const finals: FinalResultRow[] = [
+ { cost_usd: 0.05, model: "claude-sonnet-4-6", usage: null },
+ ];
+ const r = foldFinals(finals, calcTokenCost);
+ assert.equal(r.cost, 0.05);
+ assert.equal(r.totalTokens, 0);
+ assert.equal(r.contextTokens, 0);
+ });
+
+ it("keeps the last turn's model as the conversation model", () => {
+ const finals: FinalResultRow[] = [
+ { cost_usd: 0.01, model: "claude-sonnet-4-6", usage: null },
+ { cost_usd: 0.01, model: "claude-opus-4-8", usage: null },
+ ];
+ assert.equal(foldFinals(finals, calcTokenCost).model, "claude-opus-4-8");
+ });
+});
diff --git a/app/tests/date-utils.test.ts b/app/tests/date-utils.test.ts
new file mode 100644
index 000000000..da161965c
--- /dev/null
+++ b/app/tests/date-utils.test.ts
@@ -0,0 +1,18 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { isoToLocalDate } from "../src/lib/date-utils.ts";
+
+describe("isoToLocalDate", () => {
+ it("converts a UTC ISO string to a YYYY-MM-DD local date", () => {
+ // Noon UTC does not shift the calendar day in any common timezone.
+ assert.match(isoToLocalDate("2026-06-15T12:00:00.000Z"), /^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it("returns the expected date for a midday timestamp", () => {
+ assert.equal(isoToLocalDate("2026-01-05T12:00:00.000Z"), "2026-01-05");
+ });
+
+ it("zero-pads month and day", () => {
+ assert.match(isoToLocalDate("2026-03-07T12:00:00.000Z"), /^2026-03-07$/);
+ });
+});
diff --git a/app/tests/short-model.test.ts b/app/tests/short-model.test.ts
new file mode 100644
index 000000000..dbde05242
--- /dev/null
+++ b/app/tests/short-model.test.ts
@@ -0,0 +1,20 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { shortModel } from "../src/lib/usage-format.ts";
+
+describe("shortModel", () => {
+ it("formats Claude full ids with a dot-separated version", () => {
+ assert.equal(shortModel("claude-sonnet-4-6"), "Sonnet 4.6");
+ assert.equal(shortModel("claude-opus-4-8"), "Opus 4.8");
+ assert.equal(shortModel("claude-opus-4-7"), "Opus 4.7");
+ });
+
+ it("formats the OpenAI version id", () => {
+ assert.equal(shortModel("gpt-5.5"), "GPT-5.5");
+ });
+
+ it("title-cases legacy shorthand aliases", () => {
+ assert.equal(shortModel("sonnet"), "Sonnet");
+ assert.equal(shortModel("opus"), "Opus");
+ });
+});
diff --git a/app/tests/token-pricing.test.ts b/app/tests/token-pricing.test.ts
new file mode 100644
index 000000000..f41465a97
--- /dev/null
+++ b/app/tests/token-pricing.test.ts
@@ -0,0 +1,72 @@
+import { describe, it } from "node:test";
+import assert from "node:assert/strict";
+import { calcTokenCost } from "../src/lib/token-pricing.ts";
+
+// Prices verified June 2026:
+// Claude — platform.claude.com/docs/en/docs/about-claude/pricing
+// OpenAI — developers.openai.com/api/docs/pricing
+
+describe("calcTokenCost", () => {
+ it("returns null for an unpriced model (e.g. Gemini)", () => {
+ assert.equal(calcTokenCost("gemini-2.5-pro", 1000, 100, 0), null);
+ });
+
+ it("computes Claude Sonnet 4.6 cost (input $3, output $15)", () => {
+ const cost = calcTokenCost("claude-sonnet-4-6", 100_000, 10_000, 0);
+ assert.ok(cost != null);
+ const expected = (100_000 * 3.0 + 10_000 * 15.0) / 1_000_000;
+ assert.ok(Math.abs(cost - expected) < 1e-9);
+ });
+
+ it("computes Claude Opus 4.8 cost (input $5, output $25)", () => {
+ const cost = calcTokenCost("claude-opus-4-8", 100_000, 10_000, 0);
+ assert.ok(cost != null);
+ const expected = (100_000 * 5.0 + 10_000 * 25.0) / 1_000_000;
+ assert.ok(Math.abs(cost - expected) < 1e-9);
+ });
+
+ it("bills Claude cached tokens at the 0.1x cache-read rate", () => {
+ // Sonnet: cacheRead $0.30. 50K fresh + 50K cached + 5K output.
+ const cost = calcTokenCost("claude-sonnet-4-6", 100_000, 5_000, 50_000);
+ assert.ok(cost != null);
+ const expected = (50_000 * 3.0 + 50_000 * 0.3 + 5_000 * 15.0) / 1_000_000;
+ assert.ok(Math.abs(cost - expected) < 1e-9);
+ });
+
+ it("computes gpt-5.5 cost (input $5, output $30)", () => {
+ const cost = calcTokenCost("gpt-5.5", 100_000, 10_000, 0);
+ assert.ok(cost != null);
+ const expected = (100_000 * 5.0 + 10_000 * 30.0) / 1_000_000;
+ assert.ok(Math.abs(cost - expected) < 1e-9);
+ });
+
+ it("resolves legacy 'sonnet' alias to claude-sonnet-4-6", () => {
+ assert.equal(
+ calcTokenCost("sonnet", 100_000, 10_000, 0),
+ calcTokenCost("claude-sonnet-4-6", 100_000, 10_000, 0),
+ );
+ });
+
+ it("resolves legacy 'opus' alias to claude-opus-4-7", () => {
+ assert.equal(
+ calcTokenCost("opus", 100_000, 10_000, 0),
+ calcTokenCost("claude-opus-4-7", 100_000, 10_000, 0),
+ );
+ });
+
+ it("resolves a suffixed model id to its base rate via prefix match", () => {
+ assert.equal(
+ calcTokenCost("claude-sonnet-4-6-20260101", 10_000, 1_000, 0),
+ calcTokenCost("claude-sonnet-4-6", 10_000, 1_000, 0),
+ );
+ });
+
+ it("returns zero for zero tokens", () => {
+ assert.equal(calcTokenCost("claude-sonnet-4-6", 0, 0, 0), 0);
+ });
+
+ it("clamps fresh tokens to zero when cachedTokens exceeds contextTokens", () => {
+ const cost = calcTokenCost("claude-sonnet-4-6", 1_000, 100, 5_000);
+ assert.ok(cost != null && cost >= 0);
+ });
+});
diff --git a/engine/houston-agents-conversations/src/session_runner.rs b/engine/houston-agents-conversations/src/session_runner.rs
index 9acd8a2eb..90d40fbfe 100644
--- a/engine/houston-agents-conversations/src/session_runner.rs
+++ b/engine/houston-agents-conversations/src/session_runner.rs
@@ -109,6 +109,11 @@ pub fn spawn_and_monitor(
let provider_kind = provider;
let provider_str = provider.to_string();
+ // Keep a copy of the resolved model name for DB persistence. The value
+ // moves into spawn_session below and is not accessible inside the async
+ // block otherwise.
+ let model_for_persist = model.clone();
+
let (mut rx, _handle) = SessionManager::spawn_session(
provider,
prompt,
@@ -274,7 +279,7 @@ pub fn spawn_and_monitor(
// Persist non-streaming items once the provider session id is known.
if let Some(ref opts) = persist {
if let (Some(sid), Some((ft, dj))) =
- (opts.claude_session_id.as_ref(), serialize_for_persist(item))
+ (opts.claude_session_id.as_ref(), serialize_for_persist(item, model_for_persist.as_deref()))
{
let db = opts.db.clone();
let src = opts.source.clone();
@@ -415,7 +420,11 @@ pub fn spawn_and_monitor(
/// Serialize a FeedItem for DB persistence. Returns None for streaming items
/// (they get replaced by their final versions).
-fn serialize_for_persist(item: &FeedItem) -> Option<(String, String)> {
+///
+/// `model` is injected into `final_result` rows so the analytics layer can
+/// compute cost for providers (e.g. Codex) that don't report `cost_usd`
+/// themselves. Absent on rows persisted before this field was added.
+fn serialize_for_persist(item: &FeedItem, model: Option<&str>) -> Option<(String, String)> {
match item {
FeedItem::AssistantText(t) => Some(("assistant_text".into(), json_str(t))),
FeedItem::UserMessage(t) => Some(("user_message".into(), json_str(t))),
@@ -440,9 +449,15 @@ fn serialize_for_persist(item: &FeedItem) -> Option<(String, String)> {
} => {
// `usage` serializes to its TokenUsage object (or null) so the
// context-usage indicator survives a history reload, same as the
- // live FeedItem event.
+ // live FeedItem event. `model` is persisted so the analytics layer
+ // can compute cost for providers (e.g. Codex) that don't report
+ // `cost_usd` themselves.
let data = serde_json::json!({
- "result": result, "cost_usd": cost_usd, "duration_ms": duration_ms, "usage": usage
+ "result": result,
+ "cost_usd": cost_usd,
+ "duration_ms": duration_ms,
+ "usage": usage,
+ "model": model,
});
Some(("final_result".into(), data.to_string()))
}
@@ -710,7 +725,7 @@ mod tests {
details: "exec failed".to_string(),
};
- let (feed_type, data) = serialize_for_persist(&item).expect("serializes");
+ let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes");
assert_eq!(feed_type, "tool_runtime_error");
assert_eq!(data, r#"{"details":"exec failed","kind":"local_tool"}"#);
@@ -731,13 +746,52 @@ mod tests {
}),
};
- let (feed_type, data) = serialize_for_persist(&item).expect("serializes");
+ let (feed_type, data) = serialize_for_persist(&item, Some("claude-sonnet-4-6")).expect("serializes");
assert_eq!(feed_type, "final_result");
let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
assert_eq!(parsed["usage"]["context_tokens"], 151_500);
assert_eq!(parsed["usage"]["cached_tokens"], 150_000);
assert_eq!(parsed["usage"]["output_tokens"], 420);
+ assert_eq!(parsed["model"], "claude-sonnet-4-6");
+ }
+
+ #[test]
+ fn final_result_persists_model_for_cost_analytics() {
+ // The analytics layer needs the model to compute cost for providers
+ // (e.g. Codex) that don't report cost_usd. `gpt-5.5` is the OpenAI id
+ // Houston's picker produces.
+ let item = FeedItem::FinalResult {
+ result: "Done".to_string(),
+ cost_usd: None,
+ duration_ms: Some(3000),
+ usage: Some(houston_terminal_manager::TokenUsage {
+ context_tokens: 10_000,
+ output_tokens: 500,
+ cached_tokens: 8_000,
+ }),
+ };
+
+ let (_, data) = serialize_for_persist(&item, Some("gpt-5.5")).expect("serializes");
+ let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
+ assert_eq!(parsed["model"], "gpt-5.5");
+ assert!(parsed["cost_usd"].is_null());
+ }
+
+ #[test]
+ fn final_result_model_null_when_not_provided() {
+ // Rows persisted before the model field was added (or sessions where
+ // the model is unknown) must not fail deserialization.
+ let item = FeedItem::FinalResult {
+ result: "Done".to_string(),
+ cost_usd: Some(0.05),
+ duration_ms: None,
+ usage: None,
+ };
+
+ let (_, data) = serialize_for_persist(&item, None).expect("serializes");
+ let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
+ assert!(parsed["model"].is_null());
}
#[test]
@@ -749,7 +803,7 @@ mod tests {
pre_tokens: Some(185_000),
};
- let (feed_type, data) = serialize_for_persist(&item).expect("serializes");
+ let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes");
assert_eq!(feed_type, "context_compacted");
let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
@@ -764,7 +818,7 @@ mod tests {
pre_tokens: None,
};
- let (feed_type, data) = serialize_for_persist(&item).expect("serializes");
+ let (feed_type, data) = serialize_for_persist(&item, None).expect("serializes");
assert_eq!(feed_type, "context_compacted");
let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
@@ -781,7 +835,7 @@ mod tests {
usage: None,
};
- let (_, data) = serialize_for_persist(&item).expect("serializes");
+ let (_, data) = serialize_for_persist(&item, None).expect("serializes");
let parsed: serde_json::Value = serde_json::from_str(&data).unwrap();
assert!(parsed["usage"].is_null());
}