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
21 changes: 21 additions & 0 deletions extensions/shared/context-utilization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/** Compact, defensive context-window utilization formatting for child agents. */

import type { SubagentSpend } from "../subagents/src/domain.ts";

export interface ContextUtilization {
/** Current conversation context occupancy; null while it is unknown after compaction. */
tokens?: number | null;
Expand Down Expand Up @@ -45,3 +47,22 @@ export function formatContextUtilization(usage: ContextUtilization) {
const percent = contextPercent(usage);
return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
}

/**
* Render cumulative spend as `"<in> in (<cached> cached) · <out> out"` with an
* optional `" · $<cost>"` tail (4 decimals below $1, 2 decimals otherwise).
* Returns `undefined` when spend is absent or all token fields are zero, so
* callers can omit the statistic entirely.
*/
export function formatSubagentSpend(spend?: SubagentSpend) {
if (!spend) return undefined;
const { input, output, cacheRead, cacheWrite, cost } = spend;
if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0)
return undefined;
const inTokens = input + cacheRead + cacheWrite;
const cachedPart =
cacheRead > 0 ? ` (${formatCompactTokens(cacheRead)} cached)` : "";
let text = `${formatCompactTokens(inTokens)} in${cachedPart} · ${formatCompactTokens(output)} out`;
if (cost > 0) text += ` · $${cost < 1 ? cost.toFixed(4) : cost.toFixed(2)}`;
return text;
}
4 changes: 4 additions & 0 deletions extensions/shared/web-observer-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
LiveToolState,
SubagentSnapshot,
SubagentSpend,
TranscriptItem,
TranscriptPart,
} from "../subagents/src/domain.ts";
Expand Down Expand Up @@ -62,6 +63,8 @@ export interface WebSubagentDetail extends WebSubagentActivity {
readonly liveTools: readonly LiveToolState[];
readonly finalText: string;
readonly errorText?: string;
/** Cumulative token spend (and cost when known) for the child session. */
readonly spend?: SubagentSpend;
readonly truncated: boolean;
readonly omittedEntries: number;
}
Expand Down Expand Up @@ -405,6 +408,7 @@ function boundedAgentDetail<Status extends WebWorkflowAgentActivity["status"]>(
liveTools,
finalText,
...(errorText !== undefined ? { errorText } : {}),
...(source.spend !== undefined ? { spend: source.spend } : {}),
truncated:
truncated ||
omittedEntries > 0 ||
Expand Down
23 changes: 22 additions & 1 deletion extensions/subagents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ import {
} from "../shared/child-session.ts";
import { onSetupApply } from "../shared/setup-apply.ts";
import { completionOwnerFor } from "../shared/completion-inbox.ts";
import { formatContextUtilization } from "../shared/context-utilization.ts";
import {
formatContextUtilization,
formatSubagentSpend,
} from "../shared/context-utilization.ts";
import {
registerEditorLayer,
removeEditorLayer,
Expand Down Expand Up @@ -122,6 +125,7 @@ import {
formatElapsed,
latestText,
type SubagentSnapshot,
type SubagentSpend,
} from "./src/domain.ts";
import {
restoreSubagentIdCounters,
Expand Down Expand Up @@ -205,6 +209,7 @@ interface SubagentResultDetails {
readonly outcome?: SubagentSnapshot["outcome"];
readonly worktreeBranch?: string;
readonly elapsed?: string;
readonly spend?: SubagentSpend;
readonly artifactSaveFailed?: boolean;
readonly fullResultSaved?: boolean;
readonly structured?: unknown;
Expand All @@ -218,6 +223,7 @@ interface SubagentResultDetails {
readonly outcome?: SubagentSnapshot["outcome"];
readonly worktreeBranch?: string;
readonly elapsed?: string;
readonly spend?: SubagentSpend;
readonly artifactSaveFailed?: boolean;
readonly fullResultSaved?: boolean;
readonly structured?: unknown;
Expand Down Expand Up @@ -246,6 +252,7 @@ function describeSubagent(snap: SubagentSnapshot) {
const details = [
`${snap.backend}: ${snap.meta.modelLabel ?? "?"}`,
formatContextUtilization(snap.usage),
formatSubagentSpend(snap.spend),
formatElapsed(snap),
snap.cwd,
].filter(Boolean);
Expand Down Expand Up @@ -399,6 +406,7 @@ export function createSubagentResultDispatcher(
? { worktreeBranch: snaps[0]!.worktreeBranch }
: {}),
elapsed: formatElapsed(snaps[0]!),
...(snaps[0]!.spend ? { spend: snaps[0]!.spend } : {}),
...(projections[0]!.artifactPath ? { fullResultSaved: true } : {}),
...(projections[0]!.artifactSaveFailed
? { artifactSaveFailed: true }
Expand All @@ -423,6 +431,7 @@ export function createSubagentResultDispatcher(
? { worktreeBranch: snap.worktreeBranch }
: {}),
elapsed: formatElapsed(snap),
...(snap.spend ? { spend: snap.spend } : {}),
...(projections[index]!.artifactPath
? { fullResultSaved: true }
: {}),
Expand Down Expand Up @@ -476,6 +485,7 @@ function renderSubagentResult(
outcome: details.outcome,
worktreeBranch: details.worktreeBranch,
elapsed: details.elapsed,
spend: details.spend,
artifactSaveFailed: details.artifactSaveFailed,
fullResultSaved: details.fullResultSaved,
},
Expand Down Expand Up @@ -507,13 +517,23 @@ function renderSubagentResult(
: displayContent.split("\n").slice(1).join("\n").trim();
const md = new Markdown(body, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
const spendLines = results.flatMap((result) => {
const text = formatSubagentSpend(result.spend);
if (!text) return [];
return [batched ? `${sanitizeText(result.id)} · ${text}` : text];
});
const spendText = spendLines.length
? new Text(theme.fg("dim", spendLines.join("\n")), 0, 0)
: undefined;
return {
render: (width: number) => [
...container.render(width),
...(spendText?.render(width) ?? []),
...md.render(width),
],
invalidate: () => {
container.invalidate();
spendText?.invalidate();
md.invalidate();
},
};
Expand Down Expand Up @@ -1384,6 +1404,7 @@ export default function (
? { worktreeBranch: snap.worktreeBranch }
: {}),
...(snap ? { elapsed: formatElapsed(snap) } : {}),
...(snap?.spend ? { spend: snap.spend } : {}),
...(fullResultsSaved.has(id) ? { fullResultSaved: true } : {}),
...(artifactSaveFailures.has(id)
? { artifactSaveFailed: true }
Expand Down
6 changes: 5 additions & 1 deletion extensions/subagents/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
import { spinnerFrame } from "../shared/spinner.ts";
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts";
import { contextPercent } from "../shared/context-utilization.ts";
import {
contextPercent,
formatSubagentSpend,
} from "../shared/context-utilization.ts";

export interface SubagentStripEntry {
snapshot: SubagentSnapshot;
Expand Down Expand Up @@ -170,6 +173,7 @@ export class SubagentStripWidget {
...activity,
formatElapsed(snapshot),
percent === undefined ? undefined : `${percent}% ctx`,
formatSubagentSpend(snapshot.spend),
],
this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
single || snapshot.status === "running"
Expand Down
19 changes: 19 additions & 0 deletions extensions/subagents/src/backends/pi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import type {
SpawnTask,
SubagentEvent,
SubagentMeta,
SubagentSpend,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
Expand Down Expand Up @@ -359,10 +360,26 @@ const makePiSession = (

const emitUsage = () => {
const usage = session.getContextUsage();
let spend: SubagentSpend | undefined;
try {
const stats = session.getSessionStats?.();
if (stats) {
spend = {
input: stats.tokens.input,
output: stats.tokens.output,
cacheRead: stats.tokens.cacheRead,
cacheWrite: stats.tokens.cacheWrite,
cost: stats.cost,
};
}
} catch {
// Session stats are observational; never fail a run over them.
}
emit({
_tag: "UsageChanged",
tokens: usage?.tokens ?? undefined,
contextWindow: activeModel()?.contextWindow ?? usage?.contextWindow,
...(spend ? { spend } : {}),
});
};

Expand Down Expand Up @@ -402,6 +419,8 @@ const makePiSession = (
const settle = (prompt = state.activePrompt) => {
if (!prompt || state.activePrompt !== prompt || state.settled) return;
state.settled = true;
// Final cumulative spend before the settled snapshot is read.
emitUsage();
const last = prompt.lastAssistant;
const partialText = prompt.liveText || prompt.finalText || undefined;
const errorText =
Expand Down
16 changes: 16 additions & 0 deletions extensions/subagents/src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,25 @@ export type SubagentEvent =
readonly _tag: "UsageChanged";
readonly tokens?: number;
readonly contextWindow?: number;
readonly spend?: SubagentSpend;
}
| { readonly _tag: "MetaChanged"; readonly meta: Partial<SubagentMeta> }
/** Non-fatal diagnostics. Fatal failures arrive as a RunSettled outcome. */
| { readonly _tag: "BackendError"; readonly message: string };

/**
* Cumulative token spend for a child session, mirroring Pi `SessionStats`.
* Unlike `usage` (context occupancy), these counters only grow and survive
* compaction; a resumed child keeps accumulating.
*/
export interface SubagentSpend {
readonly input: number;
readonly output: number;
readonly cacheRead: number;
readonly cacheWrite: number;
readonly cost: number;
}

// --- Snapshot ---------------------------------------------------------------

/**
Expand Down Expand Up @@ -253,6 +267,8 @@ export interface SubagentSnapshot {
readonly errorText?: string;
readonly meta: SubagentMeta;
readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
/** Cumulative token spend (and cost when known) across the child session. */
readonly spend?: SubagentSpend;
readonly transcript: ReadonlyArray<TranscriptItem>;
/** Monotonic version bumped on every transcript mutation (see manager). */
readonly transcriptVersion: number;
Expand Down
3 changes: 3 additions & 0 deletions extensions/subagents/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
SubagentOrigin,
SubagentMeta,
SubagentSnapshot,
SubagentSpend,
SubagentStatus,
TranscriptItem,
} from "./domain.ts";
Expand Down Expand Up @@ -115,6 +116,7 @@ interface MutableSnapshot {
errorText?: string;
meta: SubagentMeta;
usage: { tokens?: number; contextWindow?: number };
spend?: SubagentSpend;
transcript: TranscriptItem[];
/** Bumped on every transcript mutation; UI caches key on this. */
transcriptVersion: number;
Expand Down Expand Up @@ -533,6 +535,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
tokens: event.tokens ?? s.usage.tokens,
contextWindow: event.contextWindow ?? s.usage.contextWindow,
};
s.spend = event.spend ?? s.spend;
break;
case "MetaChanged":
s.meta = { ...s.meta, ...event.meta };
Expand Down
8 changes: 7 additions & 1 deletion extensions/subagents/src/ui/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import { AgentSessionPage } from "../../../shared/agent-session-page.ts";
import { hintLine, panelFrame } from "../../../shared/screen-chrome.ts";
import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
import { formatContextUtilization } from "../../../shared/context-utilization.ts";
import {
formatContextUtilization,
formatSubagentSpend,
} from "../../../shared/context-utilization.ts";
import { SPINNER_INTERVAL_MS, spinnerFrame } from "../../../shared/spinner.ts";
import type { SubagentReadModel } from "../manager.ts";
import { subagentTranscriptDocument } from "./transcript.ts";
Expand Down Expand Up @@ -359,13 +362,15 @@ export class SubagentDashboard implements Component {
const prefix = ` ${glyph} `;

const utilization = formatContextUtilization(snap.usage);
const spend = formatSubagentSpend(snap.spend);
const metadata = [
theme.fg("muted", snap.backend),
theme.fg(
"muted",
sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?",
),
...(utilization ? [theme.fg("muted", utilization)] : []),
...(spend ? [theme.fg("dim", spend)] : []),
theme.fg("muted", formatElapsed(snap)),
];
const dot = theme.fg("dim", " · ");
Expand Down Expand Up @@ -448,6 +453,7 @@ export class TakeoverView implements Component, Focusable {
options?.badge,
snap.meta.modelLabel,
formatContextUtilization(snap.usage),
formatSubagentSpend(snap.spend),
formatElapsed(snap),
],
errorText: snap.errorText,
Expand Down
6 changes: 5 additions & 1 deletion extensions/subagents/src/ui/wait-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
truncateToWidth,
} from "@earendil-works/pi-tui";
import { sanitizeText } from "../../../shared/agent-transcript.ts";
import { formatSubagentSpend } from "../../../shared/context-utilization.ts";
import type { SubagentSpend } from "../domain.ts";

const MAX_STATUS_ROWS = 4;

Expand All @@ -19,6 +21,7 @@ export interface WaitResultItem {
readonly outcome?: "completed" | "failed" | "interrupted";
readonly worktreeBranch?: string;
readonly elapsed?: string;
readonly spend?: SubagentSpend;
readonly artifactSaveFailed?: boolean;
readonly fullResultSaved?: boolean;
}
Expand Down Expand Up @@ -105,6 +108,7 @@ export function buildWaitResultPreview(
? "uncertain"
: singleLine(result.status ?? "settled");
const elapsed = result.elapsed ? singleLine(result.elapsed) : "";
const spend = formatSubagentSpend(result.spend);
const notices = [
result.artifactSaveFailed ? "artifact not saved" : "",
result.worktreeBranch
Expand All @@ -116,7 +120,7 @@ export function buildWaitResultPreview(
? theme.fg("warning", `${notices.join(" · ")} · `)
: "";
lines.push(
` ${icon} ${attention}${theme.fg("accent", id)}${title ? theme.fg("muted", ` · ${title}`) : ""}${theme.fg("dim", ` · ${status}${elapsed ? ` · ${elapsed}` : ""}`)}`,
` ${icon} ${attention}${theme.fg("accent", id)}${title ? theme.fg("muted", ` · ${title}`) : ""}${theme.fg("dim", ` · ${status}${elapsed ? ` · ${elapsed}` : ""}${spend ? ` · ${spend}` : ""}`)}`,
);
}
if (results.length > statusRows.length) {
Expand Down
Loading
Loading