sessions: correlate cross-provider work with pull requests

This commit is contained in:
reviewer 2026-07-21 11:49:02 +02:00
parent 8e1caae33a
commit b7cf5f2f95
16 changed files with 511 additions and 192 deletions

View file

@ -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: {

View file

@ -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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
// 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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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(<PullRequests period="lifetime" provider="all" />)
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 () => {

View file

@ -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 (
<div className="pr-model-list" aria-label={models.length ? `Models used: ${models.join(', ')}` : 'No model data'}>
{models.map(model => <span className="pr-model-chip" key={model}>{model}</span>)}
</div>
)
}
function openPr(event: MouseEvent<HTMLAnchorElement>, url: string): void {
@ -41,7 +43,7 @@ function openPr(event: MouseEvent<HTMLAnchorElement>, 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<HTMLTableRowElement>, toggle: () => void): void {
function rowKeyDown(event: KeyboardEvent<HTMLDivElement>, 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 && <StaleBanner error={staleError} />}
<Panel title="Spend by pull request">
<Panel title="Pull request spend">
{pullRequests && pullRequests.rows.length > 0
? <PrTable pullRequests={pullRequests} />
: <EmptyNote>PR links are captured as sessions are parsed. Once a session references a pull request, it appears here.</EmptyNote>}
@ -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<string | null>(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 (
<>
<div className="pr-scroll">
<table className="ov-models pr-table" aria-label="Spend by pull request">
<thead>
<tr>
<th>Pull request</th>
<th className="pr-models">Models</th>
<th className="num">Cost</th>
<th className="num">Sessions</th>
<th className="num">Calls</th>
<th className="num">Active</th>
<th className="pr-chevron-cell" aria-hidden="true"></th>
</tr>
</thead>
<tbody>
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</tbody>
{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.
<tfoot>
<tr className="pr-other-row">
<td className="pr-other-label">Other ({otherCount.toLocaleString('en-US')} more PRs)</td>
<td className="pr-models"></td>
<td className="num mono">{formatUsd(otherCost)}</td>
<td className="num"></td>
<td className="num"></td>
<td className="num pr-span"></td>
<td className="pr-chevron-cell"></td>
</tr>
</tfoot>
)}
</table>
<div className="pr-summary" aria-label="Pull request attribution summary">
<div className="pr-summary-item">
<span>Attributed spend</span>
<strong>{formatUsd(summable ? displayedAttributed : distinctCost)}</strong>
</div>
<div className="pr-summary-item">
<span>Pull requests</span>
<strong>{rows.length.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Linked sessions</span>
<strong>{distinctSessions.toLocaleString('en-US')}</strong>
</div>
<div className="pr-summary-item">
<span>Folded agent runs</span>
<strong>{(subagentSessions ?? 0).toLocaleString('en-US')}</strong>
</div>
</div>
<div className="pr-list-head">
<div>
<strong>Attributed pull requests</strong>
<span>Sorted by spend, highest first</span>
</div>
<span className="pr-list-count">{rows.length.toLocaleString('en-US')} total</span>
</div>
<div className="pr-list" aria-label="Spend by pull request">
{rows.map(pr => (
<PrRowView
key={pr.url}
pr={pr}
expanded={expandedUrl === pr.url}
onToggle={() => setExpandedUrl(current => current === pr.url ? null : pr.url)}
/>
))}
</div>
{summable ? (
<p className="pr-footnote">
{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.` : ''}
</p>
) : (
<p className="pr-footnote">
@ -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 (
<>
<tr
className="pr-row"
<article className={expanded ? 'pr-card is-open' : 'pr-card'}>
<div
className="pr-card-trigger"
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={onToggle}
onKeyDown={event => rowKeyDown(event, onToggle)}
>
<td className="ov-model-name">
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
</td>
<td className="pr-models">{models.length ? modelsLabel(models) : ''}</td>
<td className="num mono" {...(pr.approx ? { title: APPROX_TITLE } : {})}>
{pr.approx ? '~' : ''}{formatUsd(pr.cost)}
</td>
<td className="num">{pr.sessions.toLocaleString('en-US')}</td>
<td className="num">{pr.calls.toLocaleString('en-US')}</td>
<td className="num pr-span">{spanLabel(pr.firstStarted, pr.lastEnded)}</td>
<td className="pr-chevron-cell"><span className="pr-chevron" aria-hidden="true"></span></td>
</tr>
<div className="pr-card-identity">
<span className="pr-icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M6 7.5V19M11 5h4a3 3 0 0 1 3 3v8.5"/></svg>
</span>
<div>
<a className="pr-link" href={pr.url} title={pr.url} onClick={event => openPr(event, pr.url)}>{pr.label}</a>
<div className="pr-card-meta">
<span>{spanLabel(pr.firstStarted, pr.lastEnded)}</span>
<span>{pr.sessions.toLocaleString('en-US')} {sessionWord(pr.sessions)}</span>
<span>{pr.calls.toLocaleString('en-US')} calls</span>
</div>
</div>
</div>
<div className="pr-card-models">
<span className="pr-card-label">Models</span>
<ModelChips models={models} />
</div>
<div className="pr-card-cost">
<span className="pr-card-label">Spend</span>
<strong {...(pr.approx ? { title: APPROX_TITLE } : {})}>{pr.approx ? '~' : ''}{formatUsd(pr.cost)}</strong>
</div>
<span className="pr-chevron" aria-hidden="true"></span>
</div>
{expanded && (
<tr className="pr-detail-row">
<td className="pr-detail-cell" colSpan={7}>
<div className="pr-detail-cell">
{categories.length > 0 ? (
<div className="pr-cats" role="region" aria-label={`${pr.label} cost breakdown`}>
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<div className="pr-cat-main">
<div className="pr-detail" role="region" aria-label={`${pr.label} cost breakdown`}>
<div className="pr-detail-head">
<span>Work breakdown</span>
<strong>{formatUsd(pr.cost)} total</strong>
</div>
<div className="pr-cats">
{categories.map(cat => (
<div className="pr-cat" key={cat.name}>
<span className="pr-cat-name">{cat.name}</span>
<div className="pr-cat-bar" aria-hidden="true">
<span style={{ width: `${catMax > 0 ? cat.cost / catMax * 100 : 0}%` }} />
</div>
<strong>{formatUsd(cat.cost)}</strong>
</div>
</div>
))}
))}
</div>
</div>
) : (
<p className="pr-cat-empty">No per-turn detail (estimated from a whole-session split).</p>
)}
</td>
</tr>
</div>
)}
</>
</article>
)
}

View file

@ -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; }

View file

@ -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 }

View file

@ -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

View file

@ -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<string>,
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<string, { project: string; projectPath?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string }>()
const sessionMap = new Map<string, { project: string; projectPath?: string; workingDirectory?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; 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<string, { projectPath?: string; sessions: SessionSummary[] }>()
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<SessionSummary, string[]>(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<string, Map<string, string[]>>()
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<string, string[]>()
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
}

View file

@ -382,6 +382,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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<string>): 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<string>): 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<string>): SessionPars
toolSequence: pendingToolSequence.length > 0 ? pendingToolSequence : undefined,
userMessage: pendingUserMessage,
sessionId,
...(sessionCwd ? { projectPath: sessionCwd, workingDirectory: sessionCwd } : {}),
...(pendingLocAdded ? { locAdded: pendingLocAdded } : {}),
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),

View file

@ -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.

View file

@ -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<string, string> = {
// 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<string, string> = {
// 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'])

View file

@ -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: `<synthetic>` 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<string, number>()
for (const [raw, mc] of r.models) addToMap(shortCosts, getShortModelName(raw), mc)
for (const [raw, mc] of r.models) {
if (raw === '<synthetic>') 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 }))

View file

@ -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

View file

@ -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 } : {}),
}
}

View file

@ -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')
})
})

View file

@ -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)', () => {

View file

@ -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], [['<synthetic>', 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', () => {