Skip to content
5 changes: 3 additions & 2 deletions infra/session-tracker.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,9 @@ export class SessionTracker {
recommendation: progress?.recommendation || "none",
preview: this.#lastMessagePreview(s),
lastMessage: this.#lastMessagePreview(s),
totalTokens: Number(tokenUsage?.totalTokens || 0),
inputTokens: Number(tokenUsage?.inputTokens || 0),
outputTokens: Number(tokenUsage?.outputTokens || 0),
insights: s.insights || null,
});
};
Expand Down Expand Up @@ -2302,5 +2305,3 @@ export function _resetSingleton(nextOptions) {
_instance = new SessionTracker(nextOptions);
}
}


26 changes: 22 additions & 4 deletions infra/tui-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,17 +271,35 @@ export function buildMonitorStatsPayload({ agentPool, runtimeStats = {}, uptimeM
}

export function buildSessionsUpdatePayload(sessions = []) {
return Array.isArray(sessions) ? sessions.map((session) => ({ ...session })) : [];
return Array.isArray(sessions)
? sessions.map((session) => {
const normalized = session && typeof session === "object" ? { ...session } : {};
const tokenUsage = normalized?.insights?.tokenUsage || null;
const inputTokens = Number(normalized.inputTokens ?? tokenUsage?.inputTokens ?? 0);
const outputTokens = Number(normalized.outputTokens ?? tokenUsage?.outputTokens ?? 0);
const totalTokens = Number(
normalized.totalTokens ?? normalized.tokenCount ?? tokenUsage?.totalTokens ?? (inputTokens + outputTokens),
);
normalized.inputTokens = Number.isFinite(inputTokens) ? Math.max(0, Math.round(inputTokens)) : 0;
normalized.outputTokens = Number.isFinite(outputTokens) ? Math.max(0, Math.round(outputTokens)) : 0;
normalized.totalTokens = Number.isFinite(totalTokens) ? Math.max(0, Math.round(totalTokens)) : (normalized.inputTokens + normalized.outputTokens);
normalized.tokenCount = normalized.totalTokens;
return normalized;
})
: [];
}

export function buildSessionEventPayload(payload = {}) {
const event = payload?.event && typeof payload.event === "object"
? { ...payload.event }
: { kind: "message", ...(payload?.message ? { message: payload.message } : {}) };
const session = payload?.session && typeof payload.session === "object"
? buildSessionsUpdatePayload([payload.session])[0]
: {};
return {
sessionId: String(payload?.sessionId || payload?.session?.id || "").trim(),
taskId: String(payload?.taskId || payload?.session?.taskId || "").trim(),
session: payload?.session && typeof payload.session === "object" ? { ...payload.session } : {},
sessionId: String(payload?.sessionId || session?.id || payload?.session?.id || "").trim(),
taskId: String(payload?.taskId || session?.taskId || payload?.session?.taskId || "").trim(),
session,
event,
};
}
Expand Down
16 changes: 12 additions & 4 deletions lib/session-insights.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,16 @@ function normalizeUsage(value) {
return { input, output, total };
}

function normalizeTokenUsageMeta(meta) {
if (!meta || typeof meta !== "object") return null;
return normalizeUsage(
meta.tokenUsage
|| meta.usage
|| meta.tokens
|| (meta.inputTokens != null || meta.outputTokens != null || meta.totalTokens != null ? meta : null),
);
}

function toTimestampMs(value) {
if (value === null || value === undefined || value === "") return null;
const ms = new Date(value).getTime();
Expand Down Expand Up @@ -269,7 +279,7 @@ function buildTurnTimeline(messages = []) {
if (type === "tool_call" && String(msg?.meta?.lifecycle || "").toLowerCase() !== "started") {
entry.toolCalls += 1;
}
const usage = normalizeUsage(msg?.meta?.usage) || normalizeUsage(msg?.usage) || null;
const usage = normalizeTokenUsageMeta(msg?.meta) || normalizeUsage(msg?.usage) || null;
if (usage) {
entry.inputTokens += usage.input;
entry.outputTokens += usage.output;
Expand Down Expand Up @@ -346,7 +356,7 @@ export function buildSessionInsights(fullSession = null) {
});
}

const usage = normalizeUsage(msg?.meta?.usage) || normalizeUsage(msg?.usage) || null;
const usage = normalizeTokenUsageMeta(msg?.meta) || normalizeUsage(msg?.usage) || null;
if (usage) {
tokenUsage.inputTokens += usage.input;
tokenUsage.outputTokens += usage.output;
Expand Down Expand Up @@ -483,5 +493,3 @@ export function buildSessionInsights(fullSession = null) {





28 changes: 27 additions & 1 deletion server/ui-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13111,6 +13111,12 @@ async function buildUsageAnalytics(days) {
const dailySkills = {};
/** dailyMcp[date][tool] = count */
const dailyMcp = {};
/** dailyInputTokens[date] = total input tokens */
const dailyInputTokens = {};
/** dailyOutputTokens[date] = total output tokens */
const dailyOutputTokens = {};
/** dailyTotalTokens[date] = total tokens */
const dailyTotalTokens = {};

const allDates = new Set();

Expand All @@ -13127,6 +13133,11 @@ async function buildUsageAnalytics(days) {
if (ts > newestTs) newestTs = ts;
const day = getEntryDayKey(session, ts);
if (day) allDates.add(day);
if (day) {
dailyInputTokens[day] = (dailyInputTokens[day] || 0) + numberOrZero(session.inputTokens);
dailyOutputTokens[day] = (dailyOutputTokens[day] || 0) + numberOrZero(session.outputTokens);
dailyTotalTokens[day] = (dailyTotalTokens[day] || 0) + numberOrZero(session.tokenCount);
}

agentRuns += 1;
const exec = String(session.executor || session.model || "unknown").trim() || "unknown";
Expand Down Expand Up @@ -13201,7 +13212,15 @@ async function buildUsageAnalytics(days) {
const topSkillNames = topSkills.slice(0, 6).map((s) => s.name);
const topMcpNames = topMcpTools.slice(0, 6).map((t) => t.name);

const trend = { dates: sortedDates, agents: {}, skills: {}, mcpTools: {} };
const trend = {
dates: sortedDates,
agents: {},
skills: {},
mcpTools: {},
tokens: sortedDates.map((d) => dailyTotalTokens[d] || 0),
inputTokens: sortedDates.map((d) => dailyInputTokens[d] || 0),
outputTokens: sortedDates.map((d) => dailyOutputTokens[d] || 0),
};
for (const name of topAgentNames) {
trend.agents[name] = sortedDates.map((d) => dailyAgents[d]?.[name] || 0);
}
Expand All @@ -13212,10 +13231,17 @@ async function buildUsageAnalytics(days) {
trend.mcpTools[name] = sortedDates.map((d) => dailyMcp[d]?.[name] || 0);
}

const totalTokens = sessionWindow.reduce((sum, session) => sum + numberOrZero(session.tokenCount), 0);
const totalInputTokens = sessionWindow.reduce((sum, session) => sum + numberOrZero(session.inputTokens), 0);
const totalOutputTokens = sessionWindow.reduce((sum, session) => sum + numberOrZero(session.outputTokens), 0);

return {
agentRuns,
skillInvocations,
mcpToolCalls,
totalTokens,
totalInputTokens,
totalOutputTokens,
avgPerDay,
lastActiveAt: newestTs < Infinity && newestTs > 0 ? new Date(newestTs).toISOString() : null,
sinceAt: oldestTs < Infinity ? new Date(oldestTs).toISOString() : null,
Expand Down
79 changes: 75 additions & 4 deletions site/lib/session-insights.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -225,14 +225,83 @@ function parseContextBreakdown(text) {
function normalizeUsage(value) {
if (!value || typeof value !== "object") return null;
const input =
Number(value.input_tokens ?? value.prompt_tokens ?? value.input ?? value.prompt ?? 0) || 0;
Number(value.input_tokens ?? value.prompt_tokens ?? value.inputTokens ?? value.promptTokens ?? value.input ?? value.prompt ?? 0) || 0;
const output =
Number(value.output_tokens ?? value.completion_tokens ?? value.output ?? value.completion ?? 0) || 0;
const total = Number(value.total_tokens ?? value.total ?? input + output) || 0;
Number(value.output_tokens ?? value.completion_tokens ?? value.outputTokens ?? value.completionTokens ?? value.output ?? value.completion ?? 0) || 0;
const total = Number(value.total_tokens ?? value.totalTokens ?? value.total ?? input + output) || 0;
if (input <= 0 && output <= 0 && total <= 0) return null;
return { input, output, total };
}

function normalizeTokenUsageMeta(meta) {
if (!meta || typeof meta !== "object") return null;
return normalizeUsage(
meta.tokenUsage
|| meta.usage
|| meta.tokens
|| (meta.inputTokens != null || meta.outputTokens != null || meta.totalTokens != null ? meta : null),
);
}

function toTimestampMs(value) {
if (value === null || value === undefined || value === "") return null;
const ms = new Date(value).getTime();
return Number.isFinite(ms) ? ms : null;
}

function buildTurnTimeline(messages = []) {
const turns = new Map();
for (const msg of Array.isArray(messages) ? messages : []) {
if (!msg || !Number.isFinite(Number(msg.turnIndex))) continue;
const turnIndex = Number(msg.turnIndex);
const timestamp = String(msg.timestamp || "");
const tsMs = toTimestampMs(timestamp);
const entry = turns.get(turnIndex) || {
turn: turnIndex + 1,
turnIndex,
startedAt: null,
endedAt: null,
durationMs: null,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
toolCalls: 0,
assistantPreview: "",
};
if (tsMs !== null) {
const startedMs = toTimestampMs(entry.startedAt);
const endedMs = toTimestampMs(entry.endedAt);
if (startedMs === null || tsMs < startedMs) entry.startedAt = timestamp;
if (endedMs === null || tsMs > endedMs) entry.endedAt = timestamp;
}
const type = String(msg.type || "").toLowerCase();
const role = String(msg.role || "").toLowerCase();
if (type === "tool_call" && String(msg?.meta?.lifecycle || "").toLowerCase() !== "started") {
entry.toolCalls += 1;
}
const usage = normalizeTokenUsageMeta(msg?.meta) || normalizeUsage(msg?.usage) || null;
if (usage) {
entry.inputTokens += usage.input;
entry.outputTokens += usage.output;
entry.totalTokens += usage.total;
}
if ((role === "assistant" || type === "agent_message" || type === "assistant_message") && !entry.assistantPreview) {
entry.assistantPreview = toText(msg.content).replace(/\s+/g, " ").trim().slice(0, 180);
}
turns.set(turnIndex, entry);
}
return Array.from(turns.values())
.sort((a, b) => a.turnIndex - b.turnIndex)
.map((entry) => {
const startedMs = toTimestampMs(entry.startedAt);
const endedMs = toTimestampMs(entry.endedAt);
return {
...entry,
durationMs: startedMs !== null && endedMs !== null ? Math.max(0, endedMs - startedMs) : null,
};
});
}

export function formatCompactCount(value) {
const n = Number(value || 0);
if (!Number.isFinite(n)) return "0";
Expand Down Expand Up @@ -288,7 +357,7 @@ export function buildSessionInsights(fullSession = null) {
});
}

const usage = normalizeUsage(msg?.meta?.usage) || normalizeUsage(msg?.usage) || null;
const usage = normalizeTokenUsageMeta(msg?.meta) || normalizeUsage(msg?.usage) || null;
if (usage) {
tokenUsage.inputTokens += usage.input;
tokenUsage.outputTokens += usage.output;
Expand Down Expand Up @@ -389,6 +458,7 @@ export function buildSessionInsights(fullSession = null) {
contextWindow,
contextBreakdown,
tokenUsage,
turnTimeline: buildTurnTimeline(messages),
activityDiff: {
files: edited.map((entry) => ({
path: entry.path,
Expand Down Expand Up @@ -417,6 +487,7 @@ export function buildSessionInsights(fullSession = null) {
? persisted.contextBreakdown
: derived.contextBreakdown,
tokenUsage: persisted.tokenUsage || derived.tokenUsage,
turnTimeline: Array.isArray(persisted.turnTimeline) ? persisted.turnTimeline : derived.turnTimeline,
activityDiff: persisted.activityDiff || derived.activityDiff,
generatedAt: persisted.generatedAt || derived.generatedAt,
};
Expand Down
46 changes: 44 additions & 2 deletions site/ui/tabs/agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
workspaces as managedWorkspaces,
} from "../components/workspace-switcher.js";
import { ICONS } from "../modules/icons.js";
import { formatCompactCount } from "../modules/session-insights.js";
import { formatRelative, truncate } from "../modules/utils.js";
import { resolveSessionWorkspaceHint } from "../modules/session-api.js";
import {
Expand Down Expand Up @@ -336,6 +337,44 @@ function getFleetEntrySearchBlob(entry) {
.join(" ");
}

function getFleetEntryTokenUsage(entry) {
const session = entry?.session || null;
const tokenUsage = session?.insights?.tokenUsage || null;
const totalTokens = Number(session?.totalTokens ?? tokenUsage?.totalTokens ?? 0);
const inputTokens = Number(session?.inputTokens ?? tokenUsage?.inputTokens ?? 0);
const outputTokens = Number(session?.outputTokens ?? tokenUsage?.outputTokens ?? 0);
return {
totalTokens: Number.isFinite(totalTokens) ? totalTokens : 0,
inputTokens: Number.isFinite(inputTokens) ? inputTokens : 0,
outputTokens: Number.isFinite(outputTokens) ? outputTokens : 0,
};
}

function buildFleetEntryTokenTooltip(entry) {
const usage = getFleetEntryTokenUsage(entry);
const total = usage.totalTokens || (usage.inputTokens + usage.outputTokens);
if (total <= 0) return "No token usage yet";
const ratio = usage.outputTokens > 0
? `${(usage.inputTokens / Math.max(usage.outputTokens, 1)).toFixed(2)}:1 in/out`
: "output pending";
return `Input ${usage.inputTokens.toLocaleString()} · Output ${usage.outputTokens.toLocaleString()} · Total ${total.toLocaleString()} · ${ratio}`;
}

function renderFleetEntryTokenSplit(entry) {
const usage = getFleetEntryTokenUsage(entry);
const total = usage.totalTokens || (usage.inputTokens + usage.outputTokens);
if (total <= 0) return null;
return html`
<${Tooltip} title=${buildFleetEntryTokenTooltip(entry)} arrow>
<span class="fleet-slot-token-split" aria-label=${`Input ${usage.inputTokens} Output ${usage.outputTokens}`}>
<span class="fleet-slot-token-segment"><strong>In</strong> ${formatCompactCount(usage.inputTokens)}</span>
<span class="fleet-slot-token-divider">/</span>
<span class="fleet-slot-token-segment"><strong>Out</strong> ${formatCompactCount(usage.outputTokens)}</span>
</span>
<//>
`;
}

function getFleetEntryRelativeTime(entry) {
const raw =
entry?.slot?.startedAt
Expand Down Expand Up @@ -1919,6 +1958,11 @@ export function AgentsTab() {
${s.taskId ? ` · ${s.taskId}` : ""}
${s.branch ? ` · ${s.branch}` : ""}
</div>
<div class="task-card-meta">
${`Turns ${Number(s.turnCount || 0)}`}
${Number(s.elapsedMs || 0) > 0 ? ` · ${formatMsDuration(s.elapsedMs || 0)}` : ""}
</div>
${renderFleetEntryTokenSplit({ session: s })}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

This adds a call to renderFleetEntryTokenSplit, but site/ui/tabs/agents.js doesn’t define/import that helper (nor the related token-usage helpers). Since site/ui/* is a mirrored build, this will crash when rendering the Agents tab; port the helper(s) and required imports into the site copy as well.

Copilot uses AI. Check for mistakes.
</div>
<${Badge} status=${s.status || "idle"} text=${s.status || "idle"} />
</div>
Expand Down Expand Up @@ -2786,5 +2830,3 @@ export function FleetSessionsTab() {
`}
`;
}


26 changes: 26 additions & 0 deletions site/ui/tabs/telemetry.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,11 @@ export function TelemetryTab() {

const hasTrend = trend?.dates?.length > 0 &&
Object.keys(trendSeriesMap || {}).length > 0;
const tokenTrendSeriesMap = useMemo(() => ({
Input: trend?.inputTokens || [],
Output: trend?.outputTokens || [],
}), [trend]);
const hasTokenTrend = trend?.dates?.length > 0 && ((trend?.inputTokens || []).length > 0 || (trend?.outputTokens || []).length > 0);

const sinceLabel = formatSinceDate(data?.sinceAt);

Expand Down Expand Up @@ -786,6 +791,10 @@ export function TelemetryTab() {
value=${formatCount(lifetimeTotals?.attemptsCount || 0)} />
<${AnalyticsStat} icon="#" label="Total tokens across all attempts"
value=${formatCount(lifetimeTotals?.tokenCount || 0)} />
<${AnalyticsStat} icon="↘" label="Input tokens across all attempts"
value=${formatCount(lifetimeTotals?.inputTokens || 0)} />
<${AnalyticsStat} icon="↗" label="Output tokens across all attempts"
value=${formatCount(lifetimeTotals?.outputTokens || 0)} />
<${AnalyticsStat} icon="⏱" label="Total runtime across all attempts"
value=${formatDurationMs(lifetimeTotals?.durationMs || 0)} />
<//>
Expand Down Expand Up @@ -831,6 +840,23 @@ export function TelemetryTab() {
`}
<//>

<${Paper} elevation=${1} sx=${{ p: 2, mb: 2 }}>
<${Typography} variant="h6" gutterBottom>Token Split Trend<//>
${hasTokenTrend ? html`
<${ChartLegend}
label=${"TOKENS"}
seriesMap=${tokenTrendSeriesMap}
palette=${["#42a5f5", "#ab47bc"]}
/>
<${Paper} variant="outlined" sx=${{ p: 1 }}>
<${TrendLines} dates=${trend.dates} seriesMap=${tokenTrendSeriesMap} palette=${["#42a5f5", "#ab47bc"]} />
<//>
` : html`
<${EmptyState} title="No token split data"
description="Input and output token trends appear once sessions record usage." />
`}
<//>

<!-- Top-N bar charts row -->
<${Stack} direction=${{ xs: "column", md: "row" }} spacing=${2} sx=${{ mb: 2 }}>
<${Paper} elevation=${1} sx=${{ p: 2, flex: 1 }}>
Expand Down
Loading
Loading