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 (
<>
-
-
-
-
- Pull request
- Models
- Cost
- Sessions
- Calls
- Active
-
-
-
-
- {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.
-
-
- 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 => (
-
-
- 0 ? cat.cost / catMax * 100 : 0}%` }} />
-
-
+
+
+ Work breakdown
+ {formatUsd(pr.cost)} total
+
+
+ {categories.map(cat => (
+
{cat.name}
+
+ 0 ? cat.cost / catMax * 100 : 0}%` }} />
+
{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', () => {