diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 9f0bdb32..14e2d80f 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -220,7 +220,7 @@ export type MenubarPayload = { skills: Array<{ name: string; turns: number; cost: number }> subagents: Array<{ name: string; calls: number; cost: number }> mcpServers: Array<{ name: string; calls: number }> - // Spend by referenced pull request (top 20 by cost), attributed at turn + // Spend by referenced pull request (every PR, cost-descending), attributed at turn // granularity. Optional: older CLIs omit it, and it is absent when no PR links // were observed. Rows carry attributed cost/calls and ARE summable; // `attributedCost + unattributedCost === distinctCost`. `approx` marks a row @@ -250,8 +250,6 @@ export type MenubarPayload = { subagentSessions?: number attributedCost?: number unattributedCost?: number - otherPrCount?: number - otherPrCost?: number } } optimize: { diff --git a/app/renderer/sections/PullRequests.test.tsx b/app/renderer/sections/PullRequests.test.tsx index ae0dcf51..69efc422 100644 --- a/app/renderer/sections/PullRequests.test.tsx +++ b/app/renderer/sections/PullRequests.test.tsx @@ -53,8 +53,6 @@ const SAMPLE: PrPayload = { distinctSessions: 3, attributedCost: 330.75, unattributedCost: 45.3, - otherPrCount: 0, - otherPrCost: 0, } // Get the button-role row wrapping a given PR link, for click/keyboard toggling. @@ -71,26 +69,27 @@ describe('PullRequests', () => { openExternal.mockResolvedValue(undefined) }) - it('renders PR rows as a table with linked labels, cost, and a date span', async () => { + it('renders PR cards with linked labels, cost, activity, and a date span', async () => { getOverview.mockResolvedValue(makePayload(SAMPLE)) render() const link = await screen.findByRole('link', { name: 'getagentseal/codeburn#780' }) expect(link).toHaveAttribute('href', 'https://github.com/getagentseal/codeburn/pull/780') expect(screen.getByText('$240.50')).toBeInTheDocument() - expect(screen.getByText('512')).toBeInTheDocument() + expect(screen.getByText('512 calls')).toBeInTheDocument() expect(screen.getByText(expectedSpan(SAMPLE.rows[0]!.firstStarted, SAMPLE.rows[0]!.lastEnded))).toBeInTheDocument() // A same-day PR collapses its span to a single label. expect(screen.getByText(expectedSpan(SAMPLE.rows[1]!.firstStarted, SAMPLE.rows[1]!.lastEnded))).toBeInTheDocument() }) - it('renders the Models column with a "+N" overflow tag', async () => { + it('renders every model explicitly instead of hiding models behind an overflow count', async () => { getOverview.mockResolvedValue(makePayload(SAMPLE)) render() - // Three models collapse to the first two plus a count of the rest. - expect(await screen.findByText('fable, opus +1')).toBeInTheDocument() - // A single model renders as-is, with no overflow tag. + expect(await screen.findByText('fable')).toBeInTheDocument() + expect(screen.getByText('opus')).toBeInTheDocument() + expect(screen.getByText('haiku')).toBeInTheDocument() + expect(screen.queryByText('+1')).toBeNull() expect(screen.getByText('sonnet')).toBeInTheDocument() }) @@ -178,10 +177,11 @@ describe('PullRequests', () => { getOverview.mockResolvedValue(makePayload(SAMPLE)) render() - const note = await screen.findByText(/attributed to the rows above/) - expect(note.textContent).toContain('$330.75') - expect(note.textContent).toContain('3 PR-linked sessions') - expect(note.textContent).toContain('summable') + expect(await screen.findByText('Attributed spend')).toBeInTheDocument() + expect(screen.getByText('$330.75')).toBeInTheDocument() + expect(screen.getByLabelText('Pull request attribution summary')).toHaveTextContent('Linked sessions3') + const note = screen.getByText(/attributed turn by turn/) + expect(note.textContent).toContain('without double counting') expect(screen.getByText(/Not tied to a specific PR/).textContent).toContain('$45.30') }) @@ -189,16 +189,17 @@ describe('PullRequests', () => { getOverview.mockResolvedValue(makePayload({ ...SAMPLE, subagentSessions: 32 })) render() - const note = await screen.findByText(/attributed to the rows above/) - expect(note.textContent).toContain('3 PR-linked sessions') - expect(note.textContent).toContain('32 folded-in subagent runs') + expect(await screen.findByText('Folded agent runs')).toBeInTheDocument() + expect(screen.getByText('32')).toBeInTheDocument() + const note = screen.getByText(/attributed turn by turn/) + expect(note.textContent).toContain('32 subagent runs are included') }) it('omits the subagent note when none were folded', async () => { getOverview.mockResolvedValue(makePayload(SAMPLE)) render() - const note = await screen.findByText(/attributed to the rows above/) + const note = await screen.findByText(/attributed turn by turn/) expect(note.textContent).not.toContain('subagent') }) @@ -260,23 +261,23 @@ describe('PullRequests', () => { expect(screen.queryByText(/Not tied to a specific PR/)).toBeNull() }) - it('renders an Other (N more PRs) reconciliation row when PRs are capped', async () => { - const cappedPayload: PrPayload = { - rows: [ - { url: 'https://github.com/getagentseal/codeburn/pull/780', label: 'getagentseal/codeburn#780', cost: 200, savingsUSD: 0, sessions: 3, calls: 512, firstStarted: '2026-07-01T10:00:00Z', lastEnded: '2026-07-03T18:00:00Z', models: ['fable'], categories: [{ name: 'Feature work', cost: 200 }] }, - ], - distinctCost: 288.4, - distinctSessions: 4, - attributedCost: 288.4, - unattributedCost: 0, - otherPrCount: 5, - otherPrCost: 88.4, - } - getOverview.mockResolvedValue(makePayload(cappedPayload)) + it('renders the complete PR list without an opaque Other row', async () => { + const manyRows = Array.from({ length: 32 }, (_, index) => ({ + ...SAMPLE.rows[0]!, + url: `https://github.com/getagentseal/codeburn/pull/${800 + index}`, + label: `getagentseal/codeburn#${800 + index}`, + })) + getOverview.mockResolvedValue(makePayload({ + ...SAMPLE, + rows: manyRows, + attributedCost: manyRows.reduce((sum, row) => sum + row.cost, 0), + })) render() - expect(await screen.findByText('Other (5 more PRs)')).toBeInTheDocument() - expect(screen.getByText('$88.40')).toBeInTheDocument() + expect(await screen.findByText('getagentseal/codeburn#800')).toBeInTheDocument() + expect(screen.getByText('getagentseal/codeburn#831')).toBeInTheDocument() + expect(screen.getByText('32 total')).toBeInTheDocument() + expect(screen.queryByText(/Other \(/)).toBeNull() }) it('shows the quiet empty state (never a fake table) when no PR links exist', async () => { diff --git a/app/renderer/sections/PullRequests.tsx b/app/renderer/sections/PullRequests.tsx index cd6bb45b..c14aa8e8 100644 --- a/app/renderer/sections/PullRequests.tsx +++ b/app/renderer/sections/PullRequests.tsx @@ -27,10 +27,12 @@ function sessionWord(n: number): string { return n === 1 ? 'session' : 'sessions' } -// Up to two short model names, then a "+N" overflow tag; empty for no models. -function modelsLabel(models: string[]): string { - if (models.length <= 2) return models.join(', ') - return `${models.slice(0, 2).join(', ')} +${models.length - 2}` +function ModelChips({ models }: { models: string[] }) { + return ( +
+ {models.map(model => {model})} +
+ ) } function openPr(event: MouseEvent, url: string): void { @@ -41,7 +43,7 @@ function openPr(event: MouseEvent, url: string): void { // Keyboard activation for the button-role row, guarded so Enter/Space fired on // the inner link (its own control) never doubles up as a row toggle. -function rowKeyDown(event: KeyboardEvent, toggle: () => void): void { +function rowKeyDown(event: KeyboardEvent, toggle: () => void): void { if (event.target !== event.currentTarget) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -73,7 +75,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq return ( <> {staleError && } - + {pullRequests && pullRequests.rows.length > 0 ? : PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.} @@ -83,7 +85,7 @@ function PullRequestsPage({ pullRequests, staleError }: { pullRequests?: PullReq } function PrTable({ pullRequests }: { pullRequests: PullRequests }) { - const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost, otherPrCount, otherPrCost } = pullRequests + const { rows, distinctCost, distinctSessions, subagentSessions, attributedCost, unattributedCost } = pullRequests const [expandedUrl, setExpandedUrl] = useState(null) // Reset any open expansion when the PR set changes (a period/provider switch or // a refresh that alters the list): a stale expandedUrl would otherwise linger @@ -95,59 +97,51 @@ function PrTable({ pullRequests }: { pullRequests: PullRequests }) { // payload omits it, so the rows are not summable and the footer must differ. const summable = attributedCost !== undefined const unattributed = unattributedCost ?? 0 - const otherCount = otherPrCount ?? 0 - const otherCost = otherPrCost ?? 0 - // Reconcile to the visible numbers: sum the rounded row costs (plus any - // capped-away remainder) so the footer total equals what the eye adds up. - const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) + otherCost + // Reconcile to the visible numbers: every PR is present, so the summary is + // exactly the sum of the rounded cards a person can inspect below. + const displayedAttributed = rows.reduce((sum, row) => sum + Number(row.cost.toFixed(2)), 0) return ( <> -
- - - - - - - - - - - - - - {rows.map(pr => ( - setExpandedUrl(current => current === pr.url ? null : pr.url)} - /> - ))} - - {otherCount > 0 && ( - // A muted summary line, kept out of the sorted rows: its cost is an - // aggregate of the capped-away PRs and can exceed a visible row. - - - - - - - - - - - - )} -
Pull requestModelsCostSessionsCallsActive
Other ({otherCount.toLocaleString('en-US')} more PRs){formatUsd(otherCost)}
+
+
+ Attributed spend + {formatUsd(summable ? displayedAttributed : distinctCost)} +
+
+ Pull requests + {rows.length.toLocaleString('en-US')} +
+
+ Linked sessions + {distinctSessions.toLocaleString('en-US')} +
+
+ Folded agent runs + {(subagentSessions ?? 0).toLocaleString('en-US')} +
+
+
+
+ Attributed pull requests + Sorted by spend, highest first +
+ {rows.length.toLocaleString('en-US')} total +
+
+ {rows.map(pr => ( + setExpandedUrl(current => current === pr.url ? null : pr.url)} + /> + ))}
{summable ? (

- {formatUsd(displayedAttributed)} attributed to the rows above, across {distinctSessions.toLocaleString('en-US')} PR-linked {sessionWord(distinctSessions)} - {subagentSessions ? ` + ${subagentSessions.toLocaleString('en-US')} folded-in subagent ${subagentSessions === 1 ? 'run' : 'runs'}` : ''}. - {' '}Each turn's cost goes to the PR it was working on, so the rows are summable. + Costs are attributed turn by turn, so every row adds up without double counting. + {subagentSessions ? ` ${subagentSessions.toLocaleString('en-US')} subagent ${subagentSessions === 1 ? 'run is' : 'runs are'} included in the PR where the work happened.` : ''}

) : (

@@ -170,50 +164,63 @@ function PrRowView({ pr, expanded, onToggle }: { pr: PrRow; expanded: boolean; o const catMax = categories.length ? Math.max(...categories.map(cat => cat.cost)) : 0 return ( - <> - +

rowKeyDown(event, onToggle)} > - - openPr(event, pr.url)}>{pr.label} - - {models.length ? modelsLabel(models) : ''} - - {pr.approx ? '~' : ''}{formatUsd(pr.cost)} - - {pr.sessions.toLocaleString('en-US')} - {pr.calls.toLocaleString('en-US')} - {spanLabel(pr.firstStarted, pr.lastEnded)} - - +
+ +
+ openPr(event, pr.url)}>{pr.label} +
+ {spanLabel(pr.firstStarted, pr.lastEnded)} + {pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)} + {pr.calls.toLocaleString('en-US')} calls +
+
+
+
+ Models + +
+
+ Spend + {pr.approx ? '~' : ''}{formatUsd(pr.cost)} +
+ +
{expanded && ( - - +
{categories.length > 0 ? ( -
- {categories.map(cat => ( -
- -
+
+
+ Work breakdown + {formatUsd(pr.cost)} total +
+
+ {categories.map(cat => ( +
{cat.name} + {formatUsd(cat.cost)}
-
- ))} + ))} +
) : (

No per-turn detail (estimated from a whole-session split).

)} - - +
)} - + ) } diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index ed45b6ba..64b45c5c 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -580,31 +580,57 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .ov-models th:first-child, .ov-models td:first-child { width: 100%; text-align: left; } .ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: var(--fw-medium); text-overflow: ellipsis; } .ov-models td.mono { font-family: var(--mono); color: var(--ink); } -.pr-scroll { overflow-x: auto; } -.pr-table { min-width: 640px; } -.pr-link { color: var(--accent-text); font-weight: var(--fw-medium); text-decoration: none; cursor: pointer; } -.pr-link:hover { text-decoration: underline; } -.pr-table th.pr-models, .pr-table td.pr-models { overflow: hidden; max-width: 180px; color: var(--mut); text-align: left; text-overflow: ellipsis; } -.pr-table td.pr-span { color: var(--mut2); font-variant-numeric: tabular-nums; white-space: nowrap; } -.pr-table th.pr-chevron-cell, .pr-table td.pr-chevron-cell { width: 26px; padding: 0 8px; text-align: center; } -.pr-chevron { display: inline-block; color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease; } -.pr-row { cursor: pointer; } -.pr-row:hover { background: var(--hover); } -.pr-row:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); } -.pr-row[aria-expanded="true"] .pr-chevron { transform: rotate(90deg); } -.pr-table td.pr-detail-cell { height: auto; padding: 11px 14px; background: color-mix(in srgb, var(--panel) 84%, var(--hover)); text-align: left; white-space: normal; } -.pr-cats { display: flex; flex-direction: column; gap: 9px; max-width: 520px; } -.pr-cat { display: flex; flex-direction: column; gap: 5px; } -.pr-cat-bar { height: 3px; overflow: hidden; border-radius: 2px; background: var(--fill); } -.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); } -.pr-cat-main { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 12px; } -.pr-cat-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; } -.pr-cat-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.pr-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px; } +.pr-summary-item { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 14px 16px; border: 1px solid var(--line); border-radius: 9px; background: color-mix(in srgb, var(--panel) 92%, var(--fill)); box-shadow: 0 1px 2px rgba(0,0,0,.08), inset 0 1px 0 rgba(255,255,255,.025); } +.pr-summary-item span { color: var(--mut2); font-size: var(--fs-label); font-weight: var(--fw-strong); letter-spacing: .055em; text-transform: uppercase; } +.pr-summary-item strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 20px; font-weight: var(--fw-kpi); font-variant-numeric: tabular-nums; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; } +.pr-list-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 2px 10px; } +.pr-list-head > div { display: flex; flex-direction: column; gap: 3px; } +.pr-list-head strong { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); } +.pr-list-head span { color: var(--mut2); font-size: var(--fs-meta); } +.pr-list-count { padding: 4px 8px; border: 1px solid var(--line); border-radius: 999px; background: var(--fill); font-family: var(--mono); font-variant-numeric: tabular-nums; } +.pr-list { display: flex; flex-direction: column; gap: 8px; } +.pr-card { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); box-shadow: 0 1px 2px rgba(0,0,0,.07); transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; } +.pr-card:hover { border-color: color-mix(in srgb, var(--line) 55%, var(--mut2)); box-shadow: 0 4px 12px rgba(0,0,0,.10); transform: translateY(-1px); } +.pr-card.is-open { border-color: color-mix(in srgb, var(--accent) 32%, var(--line)); box-shadow: 0 6px 18px rgba(0,0,0,.12); } +.pr-card-trigger { display: grid; grid-template-columns: minmax(280px, 1.35fr) minmax(230px, 1fr) 100px 28px; min-height: 74px; align-items: center; gap: 18px; padding: 12px 14px; cursor: pointer; } +.pr-card-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px var(--accent); } +.pr-card-identity { display: flex; min-width: 0; align-items: center; gap: 11px; } +.pr-card-identity > div { min-width: 0; } +.pr-icon { display: grid; width: 32px; height: 32px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--accent) 25%, var(--line)); border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, var(--panel)); color: var(--accent-text); } +.pr-icon svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.pr-link, .pr-link:visited { display: block; overflow: hidden; color: var(--ink); font-size: 12.5px; font-weight: var(--fw-subhead); text-decoration: none; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.pr-link:hover, .pr-link:focus-visible { color: var(--ink); text-decoration: underline; text-decoration-color: var(--mut2); text-underline-offset: 3px; } +.pr-card-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; } +.pr-card-meta span + span::before { content: '•'; margin: 0 7px; color: var(--line); } +.pr-card-models { min-width: 0; } +.pr-card-label { display: block; margin-bottom: 6px; color: var(--mut2); font-size: var(--fs-micro); font-weight: var(--fw-strong); letter-spacing: .05em; text-transform: uppercase; } +.pr-model-list { display: flex; min-width: 0; flex-wrap: wrap; gap: 5px; } +.pr-model-chip { display: inline-flex; max-width: 100%; align-items: center; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--fill); color: var(--mut); font-family: var(--mono); font-size: 10.5px; line-height: 1; white-space: nowrap; } +.pr-card-cost { text-align: right; } +.pr-card-cost strong { color: var(--ink); font-family: var(--mono); font-size: 14px; font-weight: var(--fw-strong); font-variant-numeric: tabular-nums; } +.pr-chevron { display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid var(--line); border-radius: 7px; background: var(--fill); color: var(--mut2); font-family: system-ui, sans-serif; font-size: 17px; line-height: 1; transition: transform 140ms ease, background 140ms ease; } +.pr-card.is-open .pr-chevron { transform: rotate(90deg); } +.pr-detail-cell { padding: 16px 18px 18px 57px; border-top: 1px solid var(--line2); background: color-mix(in srgb, var(--panel) 82%, var(--fill)); } +.pr-detail { max-width: 880px; } +.pr-detail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.pr-detail-head span { color: var(--ink); font-size: var(--fs-body); font-weight: var(--fw-subhead); } +.pr-detail-head strong { color: var(--mut); font-family: var(--mono); font-size: var(--fs-meta); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; } +.pr-cats { display: flex; flex-direction: column; gap: 9px; } +.pr-cat { display: grid; grid-template-columns: minmax(110px, 160px) minmax(120px, 1fr) 72px; align-items: center; gap: 12px; } +.pr-cat-bar { height: 5px; overflow: hidden; border-radius: 3px; background: color-mix(in srgb, var(--fill) 82%, var(--mut2)); } +.pr-cat-bar span { display: block; height: 100%; border-radius: inherit; background: color-mix(in srgb, var(--accent) 58%, white); } +.pr-cat-name { overflow: hidden; color: var(--mut); font-size: 12px; font-weight: var(--fw-medium); text-overflow: ellipsis; white-space: nowrap; } +.pr-cat > strong { color: var(--ink); font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; text-align: right; } .pr-cat-empty { margin: 0; color: var(--mut2); font-size: var(--fs-meta); } -.pr-table tfoot .pr-other-row td { border-top: 1px solid var(--line); color: var(--mut2); font-style: italic; } -.pr-table td.pr-other-label { color: var(--mut2); font-weight: var(--fw-medium); font-style: italic; text-align: left; } -.pr-footnote { margin: 12px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; } +.pr-footnote { margin: 14px 2px 2px; color: var(--mut); font-size: var(--fs-meta); line-height: 1.55; } .pr-unattributed { margin: 4px 2px 2px; color: var(--mut2); font-size: var(--fs-meta); font-variant-numeric: tabular-nums; } +@media (max-width: 820px) { + .pr-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pr-card-trigger { grid-template-columns: minmax(230px, 1fr) 96px 28px; } + .pr-card-models { grid-column: 1 / -1; grid-row: 2; padding-left: 43px; } + .pr-detail-cell { padding-left: 18px; } +} .opt-waste { min-width: 0; } .opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; } .opt-findings { display: grid; min-width: 0; } diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 56b34ca5..0eb59b4a 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -13,7 +13,7 @@ import type { ParsedProviderCall } from './providers/types.js' // so sessions cached under v4 pick up the CLI-MCP attribution. // v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from // patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add. -const CODEX_CACHE_VERSION = 6 +const CODEX_CACHE_VERSION = 7 const CACHE_FILE = 'codex-results.json' type FileFingerprint = { mtimeMs: number; sizeBytes: number } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 9298b1b0..20665725 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -55,7 +55,7 @@ export type PeriodData = { } export type PullRequestsPayload = { - /// Per-PR rows, cost-descending, capped at the top 20 by the producer. + /// Every attributed PR row, cost-descending. rows: PrRow[] /// PR-linked spend, now INCLUDING the subagent runs folded into those sessions /// (so it can exceed the parents' own spend). Equals `attributedCost + @@ -67,17 +67,11 @@ export type PullRequestsPayload = { /// sessions. Each remains a standalone row in the sessions list; here it only /// explains why the totals exceed the parents' own spend. 0 when none folded. subagentSessions?: number - /// Sum of EVERY PR's attributed cost (all rows, not just the sent top 20). + /// Sum of every PR's attributed cost. attributedCost: number /// PR-linked spend not tied to any specific PR (pre-reference session /// overhead). `attributedCost + unattributedCost === distinctCost`. unattributedCost: number - /// Count of PRs beyond the sent `rows` (0 when nothing was capped). The app - /// renders an "Other (N more PRs)" summary row so the visible table still - /// reconciles to `attributedCost`. - otherPrCount: number - /// Attributed cost of those capped-away PRs (0 when nothing was capped). - otherPrCost: number } export type ProviderCost = { @@ -309,11 +303,9 @@ export type MenubarPayload = { skills: Array<{ name: string; turns: number; cost: number }> subagents: Array<{ name: string; calls: number; cost: number }> mcpServers: Array<{ name: string; calls: number }> - /// Spend attributed by referenced pull request (top 20 by cost) plus the - /// multi-link-safe distinct total. Rows are by-reference (a session - /// referencing several PRs counts toward each), so never summed. Absent when - /// no PR links were observed, and on payloads produced before the field - /// existed — the client renders its empty state for both. + /// Every pull request with attributed spend, cost-descending, plus the + /// multi-link-safe distinct total. Absent when no PR links were observed and + /// on payloads produced before the field existed. pullRequests?: PullRequestsPayload /// Per-branch spend (top 15 by cost), last-seen branch carried forward across /// each session's turns; a `null` branch is unbranched spend inside a diff --git a/src/parser.ts b/src/parser.ts index 2b09d9ab..91856cd5 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3,7 +3,7 @@ import { lstat, readFile, readdir, stat } from 'fs/promises' import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash } from './models.js' -import { sessionIdentity } from './sessions-report.js' +import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js' import { normalizeContentBlocks } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { flushCodexCache } from './codex-cache.js' @@ -1538,7 +1538,7 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set, currentTimestamp = entry.timestamp ?? '' currentSessionId = entry.sessionId ?? '' currentBranch = entryBranch - currentPrRefs = [] + currentPrRefs = extractPrUrlsFromText(text) currentSpawnIds = [] } } else if (entry.type === 'assistant') { @@ -2038,8 +2038,10 @@ async function scanProjectDirs( // one was resolved there; only re-derive if the cached region had none. let canonicalCwd = cached.canonicalCwd let canonicalProjectName = cached.canonicalProjectName + let workingDirectory = cached.workingDirectory if (canonicalCwd === undefined && newEntries) { const cwd = extractCanonicalCwd(newEntries) + workingDirectory = workingDirectory ?? cwd const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined canonicalCwd = canonical?.path canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined @@ -2065,6 +2067,7 @@ async function scanProjectDirs( fingerprint: info.fp, lastCompleteLineOffset: tracker.lastCompleteLineOffset, canonicalCwd, + ...(workingDirectory ? { workingDirectory } : {}), canonicalProjectName, mcpInventory, turns: mergedTurns, @@ -2101,6 +2104,7 @@ async function scanProjectDirs( fingerprint: info.fp, lastCompleteLineOffset: tracker.lastCompleteLineOffset, canonicalCwd: canonical?.path, + ...(cwd ? { workingDirectory: cwd } : {}), canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, mcpInventory: extractMcpInventory(entries), turns: parsedTurnsToCachedTurns(turns), @@ -2216,9 +2220,15 @@ async function scanProjectDirs( const projectName = cachedFile.canonicalProjectName ?? dirName const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source) + if (cachedFile.workingDirectory) session.workingDirectory = cachedFile.workingDirectory session.agentType = cachedFile.agentType if (everHadBranch) session.everHadBranch = true - if (cachedFile.prLinks?.length) session.prLinks = [...new Set(cachedFile.prLinks)].sort() + const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? [])) + for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link) + if (observedPrLinks.size) { + session.prLinks = [...observedPrLinks].sort() + session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference' + } if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart if (cachedFile.title) session.title = cachedFile.title // Sidechain linkage: carry the parent id (the transcript's internal @@ -2299,6 +2309,14 @@ function summarizeProject(project: string, projectPath: string, sessions: Sessio } } +// Provider-neutral explicit-reference capture. Every saved provider session +// passes through this boundary. Full URLs only: a bare "#123" is repository- +// ambiguous and must never silently move spend between repositories. +const PR_URL_IN_TEXT_RE = /https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/pull\/\d+/g +export function extractPrUrlsFromText(text: string): string[] { + return [...new Set(text.match(PR_URL_IN_TEXT_RE) ?? [])].sort() +} + function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { const tools = call.tools const usage: TokenUsage = { @@ -2329,11 +2347,13 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn { isEstimated: call.costIsEstimated, }) + const prRefs = extractPrUrlsFromText(call.userMessage) return { userMessage: call.userMessage, assistantCalls: [apiCall], timestamp: call.timestamp, sessionId: call.sessionId, + ...(prRefs.length ? { prRefs } : {}), } } @@ -2364,6 +2384,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { deduplicationKey: call.deduplicationKey, project: call.project, projectPath: call.projectPath, + workingDirectory: call.workingDirectory, toolSequence: call.toolSequence, ...(call.locAdded ? { locAdded: call.locAdded } : {}), ...(call.locRemoved ? { locRemoved: call.locRemoved } : {}), @@ -2375,10 +2396,11 @@ async function canonicalizeProviderCallProject(call: ParsedProviderCall): Promis if (!call.projectPath) return call const canonical = await resolveCanonicalProjectPath(call.projectPath) - if (!canonical.isWorktree) return call + if (!canonical.isWorktree) return { ...call, workingDirectory: call.workingDirectory ?? call.projectPath } return { ...call, + workingDirectory: call.workingDirectory ?? call.projectPath, project: projectNameFromPath(canonical.path, call.project ?? canonical.path), projectPath: canonical.path, } @@ -2437,11 +2459,13 @@ export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] { } function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn { + const prRefs = extractPrUrlsFromText(call.userMessage) return { timestamp: call.timestamp, sessionId: call.sessionId, userMessage: call.userMessage.slice(0, 2000), calls: [providerCallToCachedCall(call)], + ...(prRefs.length ? { prRefs } : {}), } } @@ -2458,16 +2482,20 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] { const key = `${call.sessionId}\0${call.turnId}` let turn = grouped.get(key) if (!turn) { + const prRefs = extractPrUrlsFromText(call.userMessage) turn = { timestamp: call.timestamp, sessionId: call.sessionId, userMessage: call.userMessage.slice(0, 2000), calls: [], + ...(prRefs.length ? { prRefs } : {}), } grouped.set(key, turn) turns.push(turn) } turn.calls.push(providerCallToCachedCall(call)) + const refs = extractPrUrlsFromText(call.userMessage) + if (refs.length) turn.prRefs = [...new Set([...(turn.prRefs ?? []), ...refs])].sort() } return turns @@ -2519,13 +2547,14 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall { // and downstream date/day filtering can slice turns without losing the anchor. function cachedTurnToClassified(turn: CachedTurn, resolvedBranch?: string): ClassifiedTurn { const branch = turn.gitBranch ?? resolvedBranch + const prRefs = turn.prRefs?.length ? turn.prRefs : extractPrUrlsFromText(turn.userMessage) const parsed: ParsedTurn = { userMessage: turn.userMessage, assistantCalls: turn.calls.map(cachedCallToApiCall), timestamp: turn.timestamp, sessionId: turn.sessionId, ...(branch ? { gitBranch: branch } : {}), - ...(turn.prRefs?.length ? { prRefs: turn.prRefs } : {}), + ...(prRefs.length ? { prRefs } : {}), ...(turn.spawnToolUseIds?.length ? { spawnToolUseIds: turn.spawnToolUseIds } : {}), } return classifyTurn(parsed) @@ -2948,7 +2977,7 @@ async function parseProviderSources( // Query-time: derive SessionSummary from all cached turns. // Uses seenKeys (shared across providers) for cross-provider dedup. - const sessionMap = new Map; title?: string }>() + const sessionMap = new Map; title?: string }>() for (const source of servedSources) { const cachedFile = section.files[source.path] @@ -2977,6 +3006,7 @@ async function parseProviderSources( if (!existing.projectPath && turn.calls[0]?.projectPath) { existing.projectPath = turn.calls[0]!.projectPath } + if (!existing.workingDirectory && turn.calls[0]?.workingDirectory) existing.workingDirectory = turn.calls[0].workingDirectory if (cachedFile.prLinks?.length) { const links = (existing.prLinks ??= new Set()) for (const link of cachedFile.prLinks) links.add(link) @@ -2986,6 +3016,7 @@ async function parseProviderSources( sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, + workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified], ...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}), ...(cachedFile.title ? { title: cachedFile.title } : {}), @@ -3025,17 +3056,23 @@ async function parseProviderSources( existingEntry.projectPath = turn.calls[0]!.projectPath } } else { - sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, turns: [classified] }) + sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified] }) } } } } const projectMap = new Map() - for (const [key, { project, projectPath, turns, prLinks, title }] of sessionMap) { + for (const [key, { project, projectPath, workingDirectory, turns, prLinks, title }] of sessionMap) { const sessionId = key.split(':')[1] ?? key const session = buildSessionSummary(sessionId, project, turns) - if (prLinks?.size) session.prLinks = [...prLinks].sort() + const explicitLinks = new Set(turns.flatMap(turn => turn.prRefs ?? [])) + for (const link of prLinks ?? []) explicitLinks.add(link) + if (explicitLinks.size) { + session.prLinks = [...explicitLinks].sort() + session.prAttributionSource = prLinks?.size ? 'transcript' : 'explicit-reference' + } + if (workingDirectory) session.workingDirectory = workingDirectory if (title) session.title = title if (session.apiCalls > 0) { const existing = projectMap.get(project) @@ -3145,6 +3182,8 @@ function isSpawnParent(session: SessionSummary): boolean { function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary): void { if (original.everHadBranch) rebuilt.everHadBranch = true if (original.prLinks?.length) rebuilt.prLinks = original.prLinks + if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource + if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory // prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at // the new boundary (see recomputeRangeStartPrRefs), not the wide range's value. if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId @@ -3299,6 +3338,134 @@ export function mergeProjectsByCrossProviderKey(projects: ProjectSummary[]): Map return mergedMap } +function summaryProvider(session: SessionSummary): string { + return session.turns.flatMap(t => t.assistantCalls)[0]?.provider ?? 'unknown' +} + +function normalizedWorkingDirectory(path: string | undefined): string | null { + if (!path?.trim()) return null + return path.trim().replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() +} + +function normalizedPrompt(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function assignCorrelatedPrs( + session: SessionSummary, + urls: readonly string[], + source: 'working-directory' | 'launcher-prompt', +): void { + if (session.prLinks?.length || urls.length === 0) return + const refs = [...new Set(urls)].sort() + session.prLinks = refs + session.prAttributionSource = source + // Seed the first turn so the existing carry-forward state machine attributes + // every later turn precisely. This is not the legacy whole-session split. + if (session.turns[0] && !session.turns[0].prRefs?.length) session.turns[0].prRefs = refs +} + +/** + * Correlate saved sessions across AI providers without timestamp guessing. + * + * Evidence, strongest first: + * 1. exact launch-prompt text embedded in a PR-linked session's shell command; + * 2. exact provider-recorded cwd shared with one unambiguous PR. + * + * Timestamps only narrow prompt comparisons for performance; they can never + * create attribution. Conflicting PR evidence is deliberately left unassigned. + */ +export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): void { + const sessions = projects.flatMap(p => p.sessions) + const linked = sessions.filter(s => s.prLinks?.length) + // Claude sidechains retain their existing fold semantics. They may provide + // evidence for a tool they launched, but must not become standalone PR rows. + const candidates = sessions.filter(s => !s.prLinks?.length && !s.parentSessionId) + const evidence = new Map(linked.map(s => [s, s.prLinks!])) + + // Resolve Claude's native parent->sidechain linkage as evidence without + // mutating the child. This lets a Codex/Gemini/etc. review launched inside a + // Claude subagent inherit the parent turn's PR while the subagent itself still + // folds exactly once under the existing accounting model. + for (const resolved of resolveSubagentAttribution(projects).values()) { + for (const child of resolved) { + // A multi-PR spawn set is valid for folding the child's own cost, but is + // too broad to identify which PR an independently saved nested review was + // about. Require one PR for cross-provider propagation. + if (child.unlinked || child.prSet?.length !== 1) continue + const matches = sessions.filter(s => !s.prLinks?.length && s.agentId === child.fold.agentId) + if (matches.length === 1) evidence.set(matches[0]!, child.prSet) + } + } + + type Launch = { atMs: number; provider: string; refs: string[]; commands: string[] } + const launches: Launch[] = [] + for (const [session, evidenceRefs] of evidence) { + // A native PR-linked session's session-level union is NOT the active PR at + // its beginning; only a range-start seed or a turn ref establishes that. + // Sidechain evidence has already been resolved to its launching parent turn, + // so it is safe to seed the otherwise ref-less child with that exact set. + let active = session.prLinks?.length ? (session.prRefsAtRangeStart ?? []) : evidenceRefs + for (const turn of session.turns) { + if (turn.prRefs?.length) active = turn.prRefs + if (active.length === 0) continue + for (const call of turn.assistantCalls) { + const commands = (call.toolSequence ?? []) + .flat() + .map(tool => typeof tool.command === 'string' ? normalizedPrompt(tool.command) : '') + .filter(command => command.length > 0) + if (commands.length === 0) continue + const atMs = Date.parse(call.timestamp || turn.timestamp) + if (Number.isFinite(atMs)) launches.push({ atMs, provider: call.provider, refs: active, commands }) + } + } + } + + const PROMPT_PREFIX = 160 + const PROMPT_MIN = 80 + const LAUNCH_WINDOW_MS = 15 * 60 * 1000 + for (const session of candidates) { + const provider = summaryProvider(session) + const prompt = session.turns + .map(t => normalizedPrompt(t.userMessage)) + .find(text => text.length >= PROMPT_MIN) + if (!prompt) continue + const prefix = prompt.slice(0, PROMPT_PREFIX) + const startedMs = Date.parse(session.firstTimestamp) + if (!Number.isFinite(startedMs)) continue + const matches = launches.filter(launch => + launch.provider !== provider + && Math.abs(launch.atMs - startedMs) <= LAUNCH_WINDOW_MS + && launch.commands.some(command => command.includes(prefix)) + ) + const refSets = new Map(matches.map(m => [m.refs.slice().sort().join('\0'), m.refs])) + if (refSets.size === 1) { + assignCorrelatedPrs(session, [...refSets.values()][0]!, 'launcher-prompt') + if (session.prLinks?.length) evidence.set(session, session.prLinks) + } + } + + // Prompt-linked sessions become valid cwd anchors too. Attribute only when an + // exact cwd maps to one PR set; a main checkout used for multiple PRs remains + // intentionally ambiguous. + const refsByCwd = new Map>() + for (const [session, evidenceRefs] of evidence) { + const cwd = normalizedWorkingDirectory(session.workingDirectory) + if (!cwd || evidenceRefs.length !== 1) continue + const refs = evidenceRefs.slice().sort() + const sets = refsByCwd.get(cwd) ?? new Map() + sets.set(refs.join('\0'), refs) + refsByCwd.set(cwd, sets) + } + for (const session of sessions) { + if (session.prLinks?.length || session.parentSessionId) continue + const cwd = normalizedWorkingDirectory(session.workingDirectory) + if (!cwd) continue + const sets = refsByCwd.get(cwd) + if (sets?.size === 1) assignCorrelatedPrs(session, [...sets.values()][0]!, 'working-directory') + } +} + export function filterProjectsByClaudeConfigSource(projects: ProjectSummary[], sourceId: string): ProjectSummary[] { const filtered: ProjectSummary[] = [] for (const project of projects) { @@ -3566,6 +3733,7 @@ async function runParse( } const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD) + correlateCrossProviderPrSessions(result) cachePut(key, result) return result } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 9fd5e357..5cdce82a 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -382,6 +382,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let sessionModel: string | undefined let sessionId = '' + let sessionCwd: string | undefined let forkedFromId = '' let forkCutoff = '' // Null sentinel rather than `0` so the FIRST event is never confused @@ -422,6 +423,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars if (entry.type === 'session_meta') { sessionId = entry.payload?.session_id ?? basename(source.path, '.jsonl') + sessionCwd = entry.payload?.cwd ?? sessionCwd forkedFromId = entry.payload?.forked_from_id ?? '' if (forkedFromId && entry.timestamp) { forkCutoff = new Date(new Date(entry.timestamp).getTime() + 5000).toISOString() @@ -561,6 +563,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined, userMessage: pendingUserMessage, sessionId, + ...(sessionCwd ? { projectPath: sessionCwd, workingDirectory: sessionCwd } : {}), ...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}), ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), @@ -677,6 +680,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined, userMessage: pendingUserMessage, sessionId, + ...(sessionCwd ? { projectPath: sessionCwd, workingDirectory: sessionCwd } : {}), ...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}), ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), diff --git a/src/providers/types.ts b/src/providers/types.ts index 75c277aa..b5396a20 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -50,6 +50,9 @@ export type ParsedProviderCall = { sessionId: string project?: string projectPath?: string + // Exact provider-recorded cwd, kept separately because projectPath may later + // canonicalize a linked worktree to its main repository. + workingDirectory?: string } // A directory or database file that a provider's discoverSessions() scans. diff --git a/src/session-cache.ts b/src/session-cache.ts index a336f1b5..461dd461 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -36,6 +36,7 @@ export type CachedCall = { deduplicationKey: string project?: string projectPath?: string + workingDirectory?: string toolSequence?: ToolCall[][] // Rich-session-capture (capture-only; no report consumes these yet). All // optional and omitted at zero/false to keep the per-call cache cost minimal. @@ -61,9 +62,9 @@ export type CachedTurn = { // previous turn's branch (a report carries the last stored value forward). // Rich-session-capture; optional, Claude only. gitBranch?: string - // Claude: GitHub PR URLs referenced during this turn, sorted and deduplicated. - // Stored per-turn directly (unlike gitBranch, no change-detection), so a turn's - // own refs are self-contained. Drives turn-level PR spend attribution. Optional. + // GitHub PR URLs referenced during this turn, sorted and deduplicated. Claude + // can provide native links; all providers can provide explicit URLs from the + // saved user message. Stored directly so each turn's refs are self-contained. prRefs?: string[] // Claude: `tool_use` ids of the `Agent`/`Task` subagent spawns in this turn. // A spawned sidechain session is folded into the launching turn by matching its @@ -82,6 +83,8 @@ export type CachedFile = { fingerprint: FileFingerprint lastCompleteLineOffset?: number canonicalCwd?: string + // Original cwd before linked-worktree canonicalization. + workingDirectory?: string canonicalProjectName?: string mcpInventory: string[] turns: CachedTurn[] @@ -200,7 +203,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // LOC deltas / interruptions / userModified / toolErrors, and session-level // title / prLinks / isSidechain. Forces one re-parse so cached sessions gain // the new optional fields. - claude: 'advisor-usage-v1-skills-rich-capture-v1', + claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1', cline: 'worktree-project-grouping-v1', codewhale: 'aggregate-session-v1-est-cost', // Bump when the Codex parser changes attribution so unchanged, already-cached @@ -210,7 +213,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: per-call LOC deltas + editFailed from // patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in // lockstep so the pre-session-cache layer re-parses too.) - codex: 'mcp-attribution-v2-est-cost-rich-capture-v1', + codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1-skills', @@ -340,6 +343,7 @@ function validateCall(c: unknown): c is CachedCall { && (o['subagentTypes'] === undefined || isStringArray(o['subagentTypes'])) && isOptionalString(o['project']) && isOptionalString(o['projectPath']) + && isOptionalString(o['workingDirectory']) && (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s)))) && isOptionalNum(o['locAdded']) && isOptionalNum(o['locRemoved']) @@ -369,6 +373,7 @@ function validateCachedFile(f: unknown): f is CachedFile { return validateFingerprint(o['fingerprint']) && isOptionalNum(o['lastCompleteLineOffset']) && isOptionalString(o['canonicalCwd']) + && isOptionalString(o['workingDirectory']) && isOptionalString(o['canonicalProjectName']) && isStringArray(o['mcpInventory']) && isOptionalString(o['title']) diff --git a/src/sessions-report.ts b/src/sessions-report.ts index 00f120b0..dc2747a5 100644 --- a/src/sessions-report.ts +++ b/src/sessions-report.ts @@ -661,12 +661,17 @@ export function buildPrAttribution(projects: ProjectSummary[]): PrAttribution { .map(([url, r]) => { // Collapse raw model names to short display names, summing costs that map // to the same short name, then order by attributed cost (name asc breaks - // ties for a stable order) and cap at the top 4 to bound the payload. + // ties for a stable order). Keep every real model: `` is an + // internal accounting bucket, not a model a person chose or can act on. + // The desktop renders the rest as wrapping chips, so an opaque "+N" never + // hides which model did the work. const shortCosts = new Map() - for (const [raw, mc] of r.models) addToMap(shortCosts, getShortModelName(raw), mc) + for (const [raw, mc] of r.models) { + if (raw === '') continue + addToMap(shortCosts, getShortModelName(raw), mc) + } const models = [...shortCosts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, 4) .map(([name]) => name) const categories = [...r.categories.entries()] .map(([cat, cost]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, cost })) diff --git a/src/types.ts b/src/types.ts index abe74c9e..d5624cfb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -94,10 +94,10 @@ export type ParsedTurn = { // the turn's entries). Captured for cost-per-branch reporting; deduped at the // cache boundary (stored per-turn only when it changes). Optional; Claude only. gitBranch?: string - // Claude Code: GitHub PR URLs referenced during this turn (`pr-link` entries - // that landed inside the turn's span), sorted and deduplicated. Drives - // turn-level PR spend attribution; the session-level `prLinks` union is built - // separately. Absent when the turn referenced no PR. Optional; Claude only. + // GitHub PR URLs referenced during this turn, sorted and deduplicated. Claude + // supplies native `pr-link` entries; every provider can contribute explicit + // URLs from its saved user message, and correlated external sessions seed the + // field deterministically. Drives turn-level PR spend attribution. prRefs?: string[] // Claude Code: the `tool_use` ids of the `Agent`/`Task` subagent spawns emitted // in this turn. A spawned sidechain session is folded back into the turn that @@ -193,6 +193,14 @@ export type SessionSourceMetadata = { export type SessionSummary = { sessionId: string project: string + /// Exact working directory recorded by the provider before git-worktree + /// canonicalization. Used to correlate sessions from different AI tools that + /// worked in the same checkout. Never synthesized from timestamps. + workingDirectory?: string + /// How this session became associated with its PR links. Native transcript + /// links are strongest; the other sources are deterministic cross-provider + /// correlations performed after all saved sessions have been parsed. + prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt' source?: SessionSourceMetadata // Claude Code only: agent type of a subagent transcript session // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for @@ -241,8 +249,8 @@ export type SessionSummary = { totalCacheWriteTokens: number apiCalls: number turns: ClassifiedTurn[] - /// GitHub PR URLs captured from the session transcript (session-level, - /// deduplicated). Absent when none were observed. + /// GitHub PR URLs captured or deterministically correlated for this session + /// (session-level, deduplicated). Absent when none were observed. prLinks?: string[] /// The PR set active at the start of the in-range turn slice: the refs of the /// last turn BEFORE the report's range start that referenced any PR. Captured diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 4235bb2e..fe7024a5 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -16,7 +16,6 @@ import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateS import { buildGranularHistory } from './granular-history.js' // Row caps for the by-PR / by-branch payload aggregations, ranked by cost. -const TOP_PULL_REQUESTS = 20 const TOP_BRANCHES = 15 export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { @@ -657,7 +656,8 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: // PULL REQUESTS + BRANCHES (all-provider path only). Both are session-layer // aggregations over the surviving-session parse, so carried history cannot // contribute — expected and fine. PR links and per-turn git branches are - // captured only from Claude transcripts today; other providers add nothing. + // captured natively by Claude or correlated from any provider's saved session + // through explicit references, exact launcher prompts, or unambiguous cwds. // Set only when non-empty so the payload omits them (and the app renders its // quiet empty state) whenever there is nothing to show. Excluded on the // Claude-config-scoped path (which replaces scanProjects with one config's @@ -666,16 +666,14 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: // One pass yields both rows and totals, so they never disagree. const { rows: prRows, totals: prTotals } = buildPrAttribution(scanProjects) if (prRows.length > 0) { - const shownRows = prRows.slice(0, TOP_PULL_REQUESTS) - const otherRows = prRows.slice(TOP_PULL_REQUESTS) currentData.pullRequests = { - rows: shownRows, + // PRs are user-auditable spend records, so never collapse the tail into + // an opaque "Other" bucket. The desktop list scrolls with the page. + rows: prRows, distinctCost: prTotals.cost, distinctSessions: prTotals.sessions, attributedCost: prTotals.attributedCost, unattributedCost: prTotals.unattributedCost, - otherPrCount: otherRows.length, - otherPrCost: otherRows.reduce((sum, r) => sum + r.cost, 0), ...(prTotals.subagentSessions > 0 ? { subagentSessions: prTotals.subagentSessions } : {}), } } diff --git a/tests/cross-provider-pr-correlation.test.ts b/tests/cross-provider-pr-correlation.test.ts new file mode 100644 index 00000000..8353957b --- /dev/null +++ b/tests/cross-provider-pr-correlation.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' + +import { correlateCrossProviderPrSessions, extractPrUrlsFromText } from '../src/parser.js' +import type { ClassifiedTurn, ParsedApiCall, ProjectSummary, SessionSummary } from '../src/types.js' + +const A = 'https://github.com/getagentseal/codeburn/pull/790' +const B = 'https://github.com/getagentseal/codeburn/pull/791' + +function call(provider: string, timestamp: string, command?: string): ParsedApiCall { + return { + provider, model: provider === 'claude' ? 'claude-opus-4-8' : 'gpt-5.6-terra', + usage: { inputTokens: 1, outputTokens: 1, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }, + costUSD: 1, tools: command ? ['Bash'] : [], mcpTools: [], skills: [], subagentTypes: [], + hasAgentSpawn: false, hasPlanMode: false, speed: 'standard', timestamp, + bashCommands: command ? ['codex'] : [], deduplicationKey: `${provider}:${timestamp}`, + ...(command ? { toolSequence: [[{ tool: 'Bash', command }]] } : {}), + } +} + +function turn(provider: string, timestamp: string, userMessage: string, refs?: string[], command?: string): ClassifiedTurn { + return { + userMessage, timestamp, sessionId: `${provider}-${timestamp}`, + assistantCalls: [call(provider, timestamp, command)], category: 'coding', retries: 0, hasEdits: false, + ...(refs ? { prRefs: refs } : {}), + } +} + +function session(opts: { id: string; provider: string; timestamp: string; message: string; refs?: string[]; cwd?: string; command?: string; parentId?: string; agentId?: string }): SessionSummary { + const turns = [turn(opts.provider, opts.timestamp, opts.message, opts.refs, opts.command)] + return { + sessionId: opts.id, project: 'codeburn', firstTimestamp: opts.timestamp, lastTimestamp: opts.timestamp, + totalCostUSD: 1, totalSavingsUSD: 0, totalInputTokens: 1, totalOutputTokens: 1, + totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, turns, + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {}, + ...(opts.refs ? { prLinks: opts.refs, prAttributionSource: 'transcript' as const } : {}), + ...(opts.cwd ? { workingDirectory: opts.cwd } : {}), + ...(opts.parentId ? { parentSessionId: opts.parentId } : {}), + ...(opts.agentId ? { agentId: opts.agentId } : {}), + } +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { project: 'codeburn', projectPath: '/repo/codeburn', sessions, totalCostUSD: sessions.length, totalSavingsUSD: 0, totalApiCalls: sessions.length, totalProxiedCostUSD: 0 } +} + +describe('provider-neutral PR references', () => { + it('extracts and deduplicates full GitHub PR URLs from any provider text', () => { + expect(extractPrUrlsFromText(`review ${A}, then ${B}; duplicate ${A}`)).toEqual([A, B]) + }) +}) + +describe('cross-provider PR correlation', () => { + const prompt = 'Adversarial review of the cross-provider pull-request attribution implementation with concrete failure scenarios.' + + it('links an externally launched saved session by exact prompt evidence', () => { + const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch review', refs: [B], command: `codex exec '${prompt}'` }) + const child = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: prompt }) + correlateCrossProviderPrSessions([project([parent, child])]) + expect(child.prLinks).toEqual([B]) + expect(child.turns[0]!.prRefs).toEqual([B]) + expect(child.prAttributionSource).toBe('launcher-prompt') + }) + + it('does not use timestamp overlap without exact prompt evidence', () => { + const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch review', refs: [B], command: `codex exec '${prompt}'` }) + const unrelated = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: 'Investigate a completely unrelated production database incident and prepare a detailed report.' }) + correlateCrossProviderPrSessions([project([parent, unrelated])]) + expect(unrelated.prLinks).toBeUndefined() + }) + + it('does not treat a session-level PR union as active before its first turn ref', () => { + const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'launch', refs: [A, B], command: `codex exec '${prompt}'` }) + delete parent.turns[0]!.prRefs + const child = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:00:05Z', message: prompt }) + correlateCrossProviderPrSessions([project([parent, child])]) + expect(child.prLinks).toBeUndefined() + }) + + it('links an exact shared cwd only when it resolves to one PR set', () => { + const cwd = '/repo/.claude/worktrees/agent-123' + const parent = session({ id: 'claude', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'work', refs: [B], cwd }) + const child = session({ id: 'gemini', provider: 'gemini', timestamp: '2026-07-21T01:00:00Z', message: 'short', cwd }) + correlateCrossProviderPrSessions([project([parent, child])]) + expect(child.prLinks).toEqual([B]) + expect(child.prAttributionSource).toBe('working-directory') + }) + + it('leaves a shared cwd unattributed when two PRs used it', () => { + const cwd = '/repo/codeburn' + const a = session({ id: 'a', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'a', refs: [A], cwd }) + const b = session({ id: 'b', provider: 'claude', timestamp: '2026-07-21T01:00:00Z', message: 'b', refs: [B], cwd }) + const candidate = session({ id: 'c', provider: 'codex', timestamp: '2026-07-21T02:00:00Z', message: 'short', cwd }) + correlateCrossProviderPrSessions([project([a, b, candidate])]) + expect(candidate.prLinks).toBeUndefined() + }) + + it('uses Claude sidechain linkage as evidence without breaking its fold semantics', () => { + const parent = session({ id: 'parent', provider: 'claude', timestamp: '2026-07-21T00:00:00Z', message: 'spawn', refs: [B] }) + parent.agentSpawnLinks = { child: 'spawn-1' } + parent.spawnPrSets = { 'spawn-1': [B] } + const child = session({ id: 'agent-child', provider: 'claude', timestamp: '2026-07-21T00:01:00Z', message: 'launch', command: `codex exec '${prompt}'`, parentId: 'parent', agentId: 'child' }) + const codex = session({ id: 'codex', provider: 'codex', timestamp: '2026-07-21T00:01:05Z', message: prompt }) + correlateCrossProviderPrSessions([project([parent, child, codex])]) + expect(child.prLinks).toBeUndefined() + expect(codex.prLinks).toEqual([B]) + expect(codex.prAttributionSource).toBe('launcher-prompt') + }) +}) diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index 81b35e30..fe9b55ae 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -57,16 +57,12 @@ describe('buildMenubarPayload', () => { distinctSessions: 1, attributedCost: 40, unattributedCost: 5, - otherPrCount: 3, - otherPrCost: 12.5, }, } const payload = buildMenubarPayload(period, [], null) expect(payload.current.pullRequests).toEqual(period.pullRequests) expect(payload.current.pullRequests!.rows[0]!.models).toEqual(['fable', 'opus']) expect(payload.current.pullRequests!.rows[0]!.categories).toEqual([{ name: 'Coding', cost: 30 }, { name: 'Debugging', cost: 10 }]) - expect(payload.current.pullRequests!.otherPrCount).toBe(3) - expect(payload.current.pullRequests!.otherPrCost).toBe(12.5) }) it('exposes period-scoped cache tokens on current, decoupled from the 365-day history backfill (#583)', () => { diff --git a/tests/sessions-by-pr.test.ts b/tests/sessions-by-pr.test.ts index cdbfff86..76683537 100644 --- a/tests/sessions-by-pr.test.ts +++ b/tests/sessions-by-pr.test.ts @@ -334,12 +334,12 @@ describe('mixed live + legacy category reconciliation (round-3 finding 1)', () = }) }) -describe('model list bounds (round-3 finding 5)', () => { - it('caps a row to the top 4 models by attributed cost', () => { +describe('model list ordering', () => { + it('keeps every model ordered by attributed cost', () => { const rows = aggregateByPr([project([ - sessionWithTurns('s', [A], [turnModels([A], [['m-f', 60], ['m-e', 50], ['m-d', 40], ['m-c', 30], ['m-b', 20], ['m-a', 10]])]), + sessionWithTurns('s', [A], [turnModels([A], [['', 70], ['m-f', 60], ['m-e', 50], ['m-d', 40], ['m-c', 30], ['m-b', 20], ['m-a', 10]])]), ])]) - expect(rows[0]!.models).toEqual(['m-f', 'm-e', 'm-d', 'm-c']) + expect(rows[0]!.models).toEqual(['m-f', 'm-e', 'm-d', 'm-c', 'm-b', 'm-a']) }) it('breaks model ties by name ascending for a stable order', () => {