diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8d6c43..b614604d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived). ### Fixed +- **OMP: side calls outside the chat transcript now count.** OMP logs `find`, `judge`, TTSR checks and cache warming as `model_usage` entries, not assistant messages, and the parser read only messages, so that spend was missing. On one real week that was 605 `find` decisions on TypeSafe Jev and a $0.048 cache warm. Each side call now counts at its reported cost, with its purpose in the tools column as `omp:find`, `omp:cache-warm` and so on, and a zero reported cost is priced from tokens. A side call never duplicates an assistant turn. OMP sessions re-parse once and settled days re-derive (daily cache v41). - **Text selection works again in the terminal dashboard.** Mouse tracking, added in 0.9.20 so the wheel scrolls the view, makes the terminal send mouse events to the application, and in most terminals that stops click and drag from selecting text, so copying a number out of the dashboard needed Shift held down. Tracking is now off by default and `m` turns it on and off while the dashboard runs. - **Cursor Enterprise and team seats connect in Plan & Usage instead of showing an unrecognized quota response.** Their `usage-summary` carries no individual meter, only a team on-demand meter, which now fills the bar under the Enterprise plan label. On other plans it is listed beside the monthly window. In the macOS app a rate limit, outage or unrecognized response no longer adds sign-in guidance, which is only shown when the session is missing or rejected. Reported in #1546, thanks @paulodearaujo. - **Windows: the Claude quota shows for Claude Code installed only inside WSL.** The tray's Plan panel and `codeburn quota` read the credential from the Windows profile only, so a WSL-only login showed nothing. Both now also read `~/.claude/.credentials.json` in each running distro's home over `\\wsl$`, never waking a stopped distro and never writing to it; when both sides are logged in, the credential that expires last wins. A slow or unreachable share times out instead of holding up the poll. `CODEBURN_WSL=off` skips it. (#1061) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index cf1c6558..5b08cbbc 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -223,7 +223,10 @@ import type { DateRange, ProjectSummary } from './types.js' // v40: Codex fork usage after the copied-history burst was clipped by the old // 5s window. Re-derive Codex slices from the updated session cache so settled // days recover those calls and tokens. -export const DAILY_CACHE_VERSION = 40 +// v41: OMP side calls (`model_usage` entries: find, judge, cache warming) now +// count. Days finalized at v40 miss them; the bump re-derives surviving days. +// Call counts and cost only rise. +export const DAILY_CACHE_VERSION = 41 const MIN_SUPPORTED_VERSION = 28 /// Providers whose per-day CALL COUNT means something different at diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 947cef5e..85dd04a0 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -70,6 +70,17 @@ type PiEntry = { timestamp?: string cwd?: string model?: string + // `model_usage` entries (OMP side calls) carry these at the top level. + provider?: string + purpose?: string + stopReason?: string + usage?: { + input?: number + output?: number + cacheRead?: number + cacheWrite?: number + cost?: { total?: number } + } message?: { role?: string content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record }> | string @@ -90,6 +101,56 @@ type PiEntry = { } } +// OMP logs side calls outside the assistant transcript as `model_usage` +// entries: find and judge decisions (Jev), cache warming, and similar. They +// never duplicate an assistant message, so each one counts as its own call. +// The tool column carries the purpose, such as `find`, so a report can split +// decision spend from chat spend. +function modelUsageCall(entry: PiEntry, source: SessionSource, sessionId: string, lineIdx: number, + sessionTimestamp: string, seenKeys: Set): ParsedProviderCall | null { + const usage = entry.usage + if (!usage) return null + const input = usage.input ?? 0 + const output = usage.output ?? 0 + const cacheRead = usage.cacheRead ?? 0 + const cacheWrite = usage.cacheWrite ?? 0 + if (input === 0 && output === 0 && cacheRead === 0) return null + const bare = entry.model ?? '' + // OMP writes routed ids such as `~typesafe/jev-latest` beside provider + // `openrouter`. The provider prefix keeps them distinct and priceable. + const model = !entry.provider || bare.startsWith(`${entry.provider}/`) ? bare : `${entry.provider}/${bare}` + if (!model) return null + const dedupKey = `${source.provider}:${source.path}:model_usage:${entry.id || entry.timestamp || String(lineIdx)}` + if (seenKeys.has(dedupKey)) return null + seenKeys.add(dedupKey) + const reported = usage.cost?.total + const costUSD = typeof reported === 'number' && Number.isFinite(reported) && reported !== 0 + ? reported + : calculateCost(model, input, output, cacheWrite, cacheRead, 0) + const timestamp = entry.timestamp || sessionTimestamp + if (!timestamp) return null + return { + provider: source.provider, + model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD, + tools: [entry.purpose ? `omp:${entry.purpose}` : 'omp:side-call'], + bashCommands: [], + skills: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId, + } +} + function getPiSessionsDir(override?: string): string { return override ?? join(homedir(), '.pi', 'agent', 'sessions') } @@ -249,6 +310,12 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars continue } + if (entry.type === 'model_usage') { + const call = modelUsageCall(entry, source, sessionId, lineIdx, sessionTimestamp, seenKeys) + if (call) yield call + continue + } + if (entry.type !== 'message') continue const msg = entry.message diff --git a/src/session-cache.ts b/src/session-cache.ts index 76d347c6..b6b38504 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -502,7 +502,9 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // costUSD: undefined, so they must re-parse once. pi: 'cwd-project-path-v1-project-group-by-abs-v1-reported-cost-v1', // project-group-by-abs-v1: shared Pi/OMP serve grouping uses abs identity. - omp: 'nested-agent-v1-reported-cost-v2-cwd-project-path-v1-project-group-by-abs-v1', + // model-usage-v1: counts OMP side calls (`model_usage` entries: find, judge, + // cache warming), so cached sessions must re-parse to gain them. + omp: 'nested-agent-v1-reported-cost-v2-cwd-project-path-v1-project-group-by-abs-v1-model-usage-v1', // archived-subtree-v1 (#1362): the subtree walk no longer filters // `time_archived IS NULL`. An archived ROOT self-heals — it was evicted as an // undiscovered non-durable source and comes back new — but a root whose CHILD diff --git a/tests/providers/omp.test.ts b/tests/providers/omp.test.ts index ea76787a..b052425e 100644 --- a/tests/providers/omp.test.ts +++ b/tests/providers/omp.test.ts @@ -399,3 +399,91 @@ describe('omp provider - JSONL parsing', () => { expect(calls).toHaveLength(0) }) }) + +function modelUsage(opts: { + id: string + purpose?: string + provider?: string + model?: string + input?: number + output?: number + cost?: number + stopReason?: string +}) { + return JSON.stringify({ + type: 'model_usage', + id: opts.id, + parentId: 'parent', + timestamp: '2026-04-14T10:01:00.000Z', + purpose: opts.purpose ?? 'find', + role: 'typesafe', + api: 'openrouter-decisions', + provider: opts.provider ?? 'openrouter', + model: opts.model ?? '~typesafe/jev-latest', + usage: { + input: opts.input ?? 6244, + output: opts.output ?? 1220, + cacheRead: 0, + cacheWrite: 0, + totalTokens: (opts.input ?? 6244) + (opts.output ?? 1220), + cost: { input: opts.cost ?? 0.000262248, output: 0, cacheRead: 0, cacheWrite: 0, total: opts.cost ?? 0.000262248 }, + }, + stopReason: opts.stopReason ?? 'stop', + }) +} + +async function parseLines(lines: string[], seen = new Set()): Promise { + const filePath = await writeSession(join(tmpDir, '--Users-test-myproject--'), 'session.jsonl', lines) + const provider = createOmpProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'omp' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call) + return calls +} + +describe('omp provider - model_usage side calls', () => { + it('counts a side call beside the assistant turn, with its reported cost and purpose', async () => { + const calls = await parseLines([ + sessionMeta(), + userMessage('find the parser'), + assistantMessage({ cost: 0.5 }), + modelUsage({ id: 'mu-1' }), + ]) + + expect(calls).toHaveLength(2) + const side = calls.find(call => call.tools.includes('omp:find'))! + expect(side.model).toBe('openrouter/~typesafe/jev-latest') + expect(side.inputTokens).toBe(6244) + expect(side.outputTokens).toBe(1220) + expect(side.costUSD).toBeCloseTo(0.000262248, 12) + expect(side.userMessage).toBe('') + expect(side.timestamp).toBe('2026-04-14T10:01:00.000Z') + expect(calls.find(call => !call.tools.includes('omp:find'))!.userMessage).toBe('find the parser') + }) + + it('prices a zero reported cost through the price table', async () => { + setPriceOverrides({ 'openrouter/~typesafe/jev-latest': { input: 0.042, output: 0 } }) + try { + const calls = await parseLines([sessionMeta(), modelUsage({ id: 'mu-2', input: 1_000_000, output: 5, cost: 0 })]) + expect(calls[0]!.costUSD).toBeCloseTo(0.042, 12) + } finally { + setPriceOverrides({}) + } + }) + + it('skips an errored side call that consumed no tokens', async () => { + const calls = await parseLines([ + sessionMeta(), + modelUsage({ id: 'mu-3', input: 0, output: 0, cost: 0, stopReason: 'error' }), + ]) + expect(calls).toHaveLength(0) + }) + + it('counts a repeated side call once across parses that share dedup keys', async () => { + const seen = new Set() + const first = await parseLines([sessionMeta(), modelUsage({ id: 'mu-4' })], seen) + const second = await parseLines([sessionMeta(), modelUsage({ id: 'mu-4' })], seen) + expect(first).toHaveLength(1) + expect(second).toHaveLength(0) + }) +})