diff --git a/.changeset/cost-squad-session.md b/.changeset/cost-squad-session.md new file mode 100644 index 000000000..a625f1b9d --- /dev/null +++ b/.changeset/cost-squad-session.md @@ -0,0 +1,6 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +`squad cost` now finds the current session from the usage ledger instead of Scribe's session logs. The interactive shell stamps every usage record with its session id (the one `/resume` restores), as a new optional `squadSessionId` field, and plain `squad cost` reports the records that share the newest one. Before, the boundary was the newest `.squad/log/*.md` file, which the shell never writes: with no session log, plain `squad cost` reported every session, and a log written by another host during a shell run hid that run's earlier turns. The summary also counts Squad sessions instead of SDK sessions, so one shell run with three agents no longer shows as four sessions. Ledgers written before this change fall back to the old cutoff, and older CLIs still read the new records. diff --git a/docs/src/content/docs/features/cost-tracking.md b/docs/src/content/docs/features/cost-tracking.md index ee636f433..cd47baf1a 100644 --- a/docs/src/content/docs/features/cost-tracking.md +++ b/docs/src/content/docs/features/cost-tracking.md @@ -1,28 +1,26 @@ # Token Usage & Cost Tracking -Squad can track token usage and estimated cost for each agent spawn, roll that data up by session, and expose it through orchestration logs, terminal summaries, and telemetry backends. +Squad records token usage and estimated cost for each turn in the interactive shell, summarizes it by session with `squad cost`, and can export token metrics to telemetry backends. --- ## Overview -- Squad tracks token usage (input/output tokens) and estimated cost per agent spawn -- Usage data is recorded in orchestration logs and available via `squad cost` CLI +- The interactive shell (`squad` with no arguments) records input tokens, output tokens and estimated cost for every coordinator and agent turn +- `squad cost` reports that usage for the current session, for every session, or for one agent - Optional budget limits can be configured per agent or per session --- ## How It Works -- The `CostTracker` class (`packages/squad-sdk/src/runtime/cost-tracker.ts`) accumulates token data -- Each orchestration log entry includes a **Token usage** row +- Each shell turn appends JSON Lines records to `.squad/log/usage.jsonl`: one when the turn starts and one with its usage when it completes +- Each record names the SDK session that ran the turn and the Squad session it belongs to. The Squad session is the shell session, the one `/resume` restores, so it covers the coordinator and every agent the shell talks to +- Hosts that run the coordinator themselves, such as Copilot CLI and VS Code, don't write this ledger +- The `CostTracker` class (`packages/squad-sdk/src/runtime/cost-tracker.ts`) accumulates token data for telemetry - OTel metrics (`squad.tokens.input`, `squad.tokens.output`, `squad.tokens.cost`) are emitted when telemetry is enabled -The orchestration log template stores usage in a markdown table row like this: - -```md -| **Token usage** | 12,450 in / 3,200 out — $0.0234 | -``` +When the ledger has no data, `squad cost` falls back to older orchestration log entries that contain a **Token usage** row and labels the totals as estimates. --- @@ -30,23 +28,30 @@ The orchestration log template stores usage in a markdown table row like this: ```bash squad cost # current session costs -squad cost --all # all historical costs -squad cost --agent fenster # costs for specific agent +squad cost --all # all recorded costs +squad cost --agent fenster # one agent's costs in the current session ``` +The current session is the Squad session of the newest record in the ledger. If that record has no Squad session because an older Squad wrote it, `squad cost` counts the records after the newest timestamped session log in `.squad/log/` instead, or all records when there is none. + **Example output:** ```text -=== Squad Cost Summary === -Total input tokens: 12,450 -Total output tokens: 3,200 -Estimated cost: $0.0234 - ---- By Agent --- - fenster: 12,450in / 3,200out ($0.0234) [1 turns, model: claude-sonnet-4.5] - ---- By Session --- - session-abc123: 12,450in / 3,200out ($0.0234) [1 turns] +💰 Squad Cost Summary +━━━━━━━━━━━━━━━━━━━━ + + Agent Input Tokens Output Tokens Est. Cost + ───────────── ────────────── ─────────────── ─────────── + EECOM 7,000 100 $0.0700 + Control 5,000 100 $0.0500 + coordinator 1,000 100 $0.0100 + ───────────── ────────────── ─────────────── ─────────── + Total 13,000 300 $0.1300 + + 📊 3 agents across 3 turns in 1 session + Models: claude-sonnet-5 (3 turns, $0.1300) + Source: structured runtime usage ledger + Coverage: 3 of 3 turn attempts with recorded usage (100.0%) ``` --- diff --git a/packages/squad-cli/src/cli/commands/cost.ts b/packages/squad-cli/src/cli/commands/cost.ts index 9a208c660..b436a75e8 100644 --- a/packages/squad-cli/src/cli/commands/cost.ts +++ b/packages/squad-cli/src/cli/commands/cost.ts @@ -2,6 +2,7 @@ import { resolveSquadState, UsageLedger, type StateBackend, + type UsageLedgerReadResult, type UsageLedgerRecord, type UsageLedgerTurnStart, } from '@bradygaster/squad-sdk'; @@ -15,6 +16,7 @@ interface CostArgs { interface CostEntry { agent: string; sessionId: string; + squadSessionId?: string; turnId?: string; model: string; inputTokens: number; @@ -97,6 +99,7 @@ function fromLedgerRecord(record: UsageLedgerRecord): CostEntry { return { agent: record.agentName ?? 'unknown', sessionId: record.sessionId, + ...(record.squadSessionId ? { squadSessionId: record.squadSessionId } : {}), ...(record.turnId ? { turnId: record.turnId } : {}), model: record.model, inputTokens: record.inputTokens, @@ -213,7 +216,7 @@ function printSummary( console.log(formatRow('Total', total.inputTokens, total.outputTokens, total.estimatedCost)); console.log(); - const sessionCount = new Set(entries.map(entry => entry.sessionId)).size; + const sessionCount = new Set(entries.map(entry => entry.squadSessionId ?? entry.sessionId)).size; const modelTotals = new Map(); for (const entry of entries) { const model = modelTotals.get(entry.model) ?? { turns: 0, estimatedCost: 0 }; @@ -261,14 +264,36 @@ function getCurrentSessionCutoff(backend: StateBackend): number | null { } } +/** + * The current session is the Squad session of the newest ledger row. When that + * row has no Squad session id (written by an older Squad), callers fall back to + * the newest session log in `log/`. + */ +function getCurrentSquadSessionId(ledger: UsageLedgerReadResult): string | undefined { + let newest: { timestamp: number; squadSessionId?: string } | undefined; + for (const row of [...ledger.records, ...ledger.turnStarts]) { + const timestamp = parseTimestamp(row.timestamp); + if (timestamp !== null && (!newest || timestamp >= newest.timestamp)) { + newest = { timestamp, squadSessionId: row.squadSessionId }; + } + } + return newest?.squadSessionId; +} + +interface StructuredScope { + cutoffTimestamp: number | null; + squadSessionId?: string; +} + function matchesScope( - record: Pick, - cutoffTimestamp: number | null, + record: Pick, + scope: StructuredScope, agentFilter?: string, ): boolean { const timestamp = parseTimestamp(record.timestamp); return timestamp !== null - && (cutoffTimestamp === null || timestamp > cutoffTimestamp) + && (scope.squadSessionId === undefined || record.squadSessionId === scope.squadSessionId) + && (scope.cutoffTimestamp === null || timestamp > scope.cutoffTimestamp) && (!agentFilter || (record.agentName ?? 'unknown').toLowerCase() === agentFilter.toLowerCase()); } @@ -286,16 +311,15 @@ function calculateCoverage(entries: CostEntry[], turnStarts: UsageLedgerTurnStar }; } -async function readStructuredEntries( - backend: StateBackend, - cutoffTimestamp: number | null, +function readStructuredEntries( + result: UsageLedgerReadResult, + scope: StructuredScope, agentFilter?: string, -): Promise<{ entries: CostEntry[]; coverage: UsageCoverage; invalidRecordCount: number }> { - const result = await new UsageLedger(backend).read(); +): { entries: CostEntry[]; coverage: UsageCoverage; invalidRecordCount: number } { const entries = result.records - .filter(record => matchesScope(record, cutoffTimestamp, agentFilter)) + .filter(record => matchesScope(record, scope, agentFilter)) .map(fromLedgerRecord); - const turnStarts = result.turnStarts.filter(record => matchesScope(record, cutoffTimestamp, agentFilter)); + const turnStarts = result.turnStarts.filter(record => matchesScope(record, scope, agentFilter)); return { entries, @@ -344,8 +368,14 @@ export async function runCost(args: string[], teamRoot: string): Promise { return; } + const ledger = await new UsageLedger(stateContext.backend).read(); const cutoffTimestamp = showAll ? null : getCurrentSessionCutoff(stateContext.backend); - const structured = await readStructuredEntries(stateContext.backend, cutoffTimestamp, agentFilter); + const squadSessionId = showAll ? undefined : getCurrentSquadSessionId(ledger); + const structured = readStructuredEntries( + ledger, + squadSessionId ? { cutoffTimestamp: null, squadSessionId } : { cutoffTimestamp }, + agentFilter, + ); if (structured.entries.length > 0) { printSummary( structured.entries, diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts index 50f9ca18f..7c87f1946 100644 --- a/packages/squad-cli/src/cli/shell/index.ts +++ b/packages/squad-cli/src/cli/shell/index.ts @@ -276,7 +276,7 @@ export async function runShell(): Promise { const nextUsageTurnId = (sessionId: string): string => `${sessionId}:${Date.now()}:${++usageTurnSequence}`; streamingPipeline.onUsage(async (event) => { - await persistUsage(ledger => ledger.append(event)); + await persistUsage(ledger => ledger.append(event, { squadSessionId: persistedSession.id })); }); if ( rawContextWarningThreshold @@ -522,6 +522,7 @@ export async function runShell(): Promise { await persistUsage(ledger => ledger.recordTurnStart({ turnId, sessionId: sid, + squadSessionId: persistedSession.id, agentName, timestamp: new Date(), })); @@ -719,6 +720,7 @@ export async function runShell(): Promise { await persistUsage(ledger => ledger.recordTurnStart({ turnId, sessionId: coordSid, + squadSessionId: persistedSession.id, agentName: 'coordinator', timestamp: new Date(), })); diff --git a/packages/squad-sdk/src/runtime/usage-ledger.ts b/packages/squad-sdk/src/runtime/usage-ledger.ts index 2af83abaf..c0b96525d 100644 --- a/packages/squad-sdk/src/runtime/usage-ledger.ts +++ b/packages/squad-sdk/src/runtime/usage-ledger.ts @@ -16,6 +16,7 @@ export interface UsageLedgerRecord { version: typeof USAGE_LEDGER_VERSION; kind: 'usage'; sessionId: string; + squadSessionId?: string; turnId?: string; agentName?: string; model: string; @@ -30,6 +31,7 @@ export interface UsageLedgerTurnStart { kind: 'turn-start'; turnId: string; sessionId: string; + squadSessionId?: string; agentName?: string; timestamp: string; } @@ -37,10 +39,21 @@ export interface UsageLedgerTurnStart { export interface UsageTurnStart { turnId: string; sessionId: string; + squadSessionId?: string; agentName?: string; timestamp: Date; } +/** Context stamped on a usage record that the runtime event does not carry. */ +export interface UsageRecordContext { + /** + * The Squad session the record belongs to. One Squad session spans several SDK + * sessions (the coordinator's and one per agent), so `sessionId` cannot mark it. + * The interactive shell passes its persisted session id, the one `/resume` restores. + */ + squadSessionId?: string; +} + export interface UsageLedgerReadResult { records: UsageLedgerRecord[]; turnStarts: UsageLedgerTurnStart[]; @@ -56,6 +69,10 @@ function isNonNegativeFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value >= 0; } +function isOptionalNonEmptyString(value: unknown): boolean { + return value === undefined || (typeof value === 'string' && value.length > 0); +} + function isUsageLedgerRecord(value: unknown): value is UsageLedgerRecord { if (!value || typeof value !== 'object') return false; @@ -64,6 +81,7 @@ function isUsageLedgerRecord(value: unknown): value is UsageLedgerRecord { && record.kind === 'usage' && typeof record.sessionId === 'string' && record.sessionId.length > 0 + && isOptionalNonEmptyString(record.squadSessionId) && (record.turnId === undefined || (typeof record.turnId === 'string' && record.turnId.length > 0)) && (record.agentName === undefined || typeof record.agentName === 'string') && typeof record.model === 'string' @@ -87,12 +105,13 @@ function isUsageLedgerTurnStart(value: unknown): value is UsageLedgerTurnStart { && record.turnId.length > 0 && typeof record.sessionId === 'string' && record.sessionId.length > 0 + && isOptionalNonEmptyString(record.squadSessionId) && (record.agentName === undefined || typeof record.agentName === 'string') && typeof record.timestamp === 'string' && Number.isFinite(Date.parse(record.timestamp)); } -function toRecord(event: UsageEvent): UsageLedgerRecord { +function toRecord(event: UsageEvent, context: UsageRecordContext = {}): UsageLedgerRecord { const record: UsageLedgerRecord = { version: USAGE_LEDGER_VERSION, kind: 'usage', @@ -102,6 +121,7 @@ function toRecord(event: UsageEvent): UsageLedgerRecord { outputTokens: event.outputTokens, estimatedCost: event.estimatedCost, timestamp: event.timestamp.toISOString(), + ...(context.squadSessionId !== undefined ? { squadSessionId: context.squadSessionId } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.agentName ? { agentName: event.agentName } : {}), }; @@ -120,6 +140,7 @@ function toTurnStart(turn: UsageTurnStart): UsageLedgerTurnStart { turnId: turn.turnId, sessionId: turn.sessionId, timestamp: turn.timestamp.toISOString(), + ...(turn.squadSessionId !== undefined ? { squadSessionId: turn.squadSessionId } : {}), ...(turn.agentName ? { agentName: turn.agentName } : {}), }; @@ -136,8 +157,8 @@ export class UsageLedger { private readonly ledgerPath: string = USAGE_LEDGER_PATH, ) {} - async append(event: UsageEvent): Promise { - await Promise.resolve(this.storage.append(this.ledgerPath, `${JSON.stringify(toRecord(event))}\n`)); + async append(event: UsageEvent, context?: UsageRecordContext): Promise { + await Promise.resolve(this.storage.append(this.ledgerPath, `${JSON.stringify(toRecord(event, context))}\n`)); } async recordTurnStart(turn: UsageTurnStart): Promise { diff --git a/test/cli/cost.test.ts b/test/cli/cost.test.ts index 37e9265be..db1aa7655 100644 --- a/test/cli/cost.test.ts +++ b/test/cli/cost.test.ts @@ -331,4 +331,95 @@ describe('CLI: cost command', () => { expect(output).toContain('Coverage: 0 of 1 turn attempt with recorded usage (0.0%)'); expect(output).not.toContain('Legacy Agent'); }); + + describe('Squad session scope', () => { + // Shaped like the interactive shell's writes: one SDK session for the + // coordinator plus one per agent, all stamped with the shell session id. + function shellTurn( + squadSessionId: string | undefined, + sessionId: string, + agentName: string, + startedAt: string, + inputTokens: number, + ): Array> { + const turnId = `${sessionId}:${startedAt}`; + const stamp = squadSessionId ? { squadSessionId } : {}; + return [ + { version: 1, kind: 'turn-start', turnId, sessionId, ...stamp, agentName, timestamp: startedAt }, + { + version: 1, + kind: 'usage', + turnId, + sessionId, + ...stamp, + agentName, + model: 'claude-sonnet-5', + inputTokens, + outputTokens: 100, + estimatedCost: inputTokens / 100000, + timestamp: new Date(Date.parse(startedAt) + 60_000).toISOString(), + }, + ]; + } + + const yesterday = (squadSessionId?: string) => [ + ...shellTurn(squadSessionId, 'sdk-coord-a', 'coordinator', '2026-10-25T09:00:00.000Z', 1000), + ...shellTurn(squadSessionId, 'sdk-flight-a', 'Flight', '2026-10-25T09:01:00.000Z', 9000), + ]; + const today = (squadSessionId?: string) => [ + ...shellTurn(squadSessionId, 'sdk-coord-b', 'coordinator', '2026-10-26T10:00:00.000Z', 1000), + ...shellTurn(squadSessionId, 'sdk-control-b', 'Control', '2026-10-26T10:01:00.000Z', 5000), + ...shellTurn(squadSessionId, 'sdk-eecom-b', 'EECOM', '2026-10-26T10:30:00.000Z', 7000), + ]; + + async function costOutput(args: string[]): Promise { + const { runCost } = await import('../../packages/squad-cli/src/cli/commands/cost.ts'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runCost(args, TEAM_ROOT); + return logSpy.mock.calls.map(call => call.join(' ')).join('\n'); + } + + it('defaults to the newest Squad session and ignores session logs', async () => { + mkdirSync(LOG_DIR, { recursive: true }); + // A session log written by another host in the middle of today's run. + writeFileSync(join(LOG_DIR, '2026-10-26T10-15-00Z-session.md'), '# session\n', 'utf8'); + writeUsageLedger([...yesterday('shell-a'), ...today('shell-b')]); + + const output = await costOutput([]); + + expect(output).not.toContain('Flight'); + expect(output).toContain('Control'); + expect(output).toContain('EECOM'); + expect(output).toContain('3 agents across 3 turns in 1 session'); + expect(output).toContain('Coverage: 3 of 3 turn attempts with recorded usage (100.0%)'); + }); + + it('counts Squad sessions rather than SDK sessions with --all', async () => { + writeUsageLedger([...yesterday('shell-a'), ...today('shell-b')]); + + const output = await costOutput(['--all']); + + expect(output).toContain('4 agents across 5 turns in 2 sessions'); + }); + + it('falls back to the newest session log when the newest record has no Squad session id', async () => { + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(join(LOG_DIR, '2026-10-26T09-00-00Z-session.md'), '# session\n', 'utf8'); + writeUsageLedger([...yesterday('shell-a'), ...today()]); + + const output = await costOutput([]); + + expect(output).not.toContain('Flight'); + expect(output).toContain('3 agents across 3 turns in 3 sessions'); + }); + + it('does not reach into older sessions when the agent filter misses the current one', async () => { + writeUsageLedger([...yesterday('shell-a'), ...today('shell-b')]); + + const output = await costOutput(['--agent', 'Flight']); + + expect(output).toContain('No token usage data found.'); + expect(output).not.toContain('9,000'); + }); + }); }); diff --git a/test/usage-ledger.test.ts b/test/usage-ledger.test.ts index 4ee22cff4..86f737fd5 100644 --- a/test/usage-ledger.test.ts +++ b/test/usage-ledger.test.ts @@ -126,4 +126,51 @@ describe('UsageLedger', () => { expect(await storage.read(USAGE_LEDGER_PATH)).toBeUndefined(); }); + + it('stamps the Squad session id on turn starts and usage', async () => { + await ledger.recordTurnStart({ + turnId: 'turn-1', + sessionId: 'sdk-session-1', + squadSessionId: 'shell-session-1', + agentName: 'fenster', + timestamp: new Date('2026-09-15T16:59:59.000Z'), + }); + await ledger.append({ + type: 'usage', + sessionId: 'sdk-session-1', + turnId: 'turn-1', + agentName: 'fenster', + model: 'claude-sonnet-5', + inputTokens: 1200, + outputTokens: 300, + estimatedCost: 0.0125, + timestamp: new Date('2026-09-15T17:00:00.000Z'), + }, { squadSessionId: 'shell-session-1' }); + + const result = await ledger.read(); + + expect(result.invalidRecordCount).toBe(0); + expect(result.turnStarts[0]?.squadSessionId).toBe('shell-session-1'); + expect(result.records[0]?.squadSessionId).toBe('shell-session-1'); + }); + + it('rejects an empty Squad session id before writing it', async () => { + await expect(ledger.recordTurnStart({ + turnId: 'turn-1', + sessionId: 'sdk-session-1', + squadSessionId: '', + timestamp: new Date('2026-09-15T17:00:00.000Z'), + })).rejects.toThrow('invalid usage turn start'); + await expect(ledger.append({ + type: 'usage', + sessionId: 'sdk-session-1', + model: 'gpt-5.6-sol', + inputTokens: 100, + outputTokens: 25, + estimatedCost: 0.001, + timestamp: new Date('2026-09-15T17:00:00.000Z'), + }, { squadSessionId: '' })).rejects.toThrow('invalid usage event'); + + expect(await storage.read(USAGE_LEDGER_PATH)).toBeUndefined(); + }); });