Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/superpowers/plans/2026-09-17-performance-monitor-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Performance Monitor overview rebuild

Issue #984, part of #944. Stacked on the monitor shipped in #951/#953.

1. Keep 15 minutes of per-agent memory and CPU in main, derived from the
cached process pages the monitor already reads (new `AgentUsageHistory`).
Attribution: a process owned by exactly one session counts toward that
agent (children included); a process shared by several sessions counts once
as shared; app/agents/terminals/shared/other always sum to the total.
Served read-only from main via `performance:monitor-agents`, so it never
waits on the helper.
2. Shared chart components (`components/charts`) used by both the monitor and
Agent Analytics: interactive time-series (real pixel axes with rounded
ceilings, lines that break at gaps, a crosshair that snaps to real samples,
shared across charts that measure the same window, thresholds, incident
markers, full keyboard + screen-reader readout), nested bars and sparklines
on a shared scale. Pure geometry split into tested `chartMath`.
3. Rebuild the monitor Overview as answers in reading order: totals strip
(memory with share of RAM and 15-min delta, CPU, heaviest agent, running
counts, UI responsiveness, heap, incidents — toned with the incident
engine's own thresholds), stacked memory/CPU charts with one crosshair,
then "Agents by resource use": every running session under its workspace
label (A15) with memory share, signed growth, CPU, process count and a
sparkline; sortable by memory/CPU/growth; "Go to" wakes, focuses and
closes only when navigation succeeds.
4. Move Timeline onto the same charts (memory/CPU/responsiveness lanes,
1h/6h ranges, Earlier/Later panning, markers that select their incident)
and show agent labels + titles in the Processes table instead of session
ID prefixes.
5. Put Agent Analytics on the shared charts: per-day bars with wall-clock
nested inside agent-hours, parallelism tile, per-project share bars.
6. Delete the two superseded debug surfaces — the old Performance panel
(`features/performance`, endpoint `performance:pane-stats`) and the System
performance badge/popover (`features/system-perf`, endpoint
`performance:system-stats`) — moving the "performance" feature-reference
page to the monitor directory that now owns those commands.

Out of scope: drag-to-zoom on the timeline (follow-up), longer/persistent
per-agent history windows.

Implementation evidence: attribution, chart-geometry, label-resolution and
chart-interaction tests pass (vitest); full `tsc -b` clean. Monitor lifecycle
tests unchanged and passing; feature-reference coverage updated for the
removed directories.
37 changes: 15 additions & 22 deletions src/main/ipc/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@ import { getHeapStatistics, writeHeapSnapshot } from 'node:v8'
import { mainProbe } from '@main/performance/MainProbe.js'
import { mkdir, rm, statfs } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { totalmem } from 'node:os'
import { moveArtifact } from '@main/performance/moveArtifact.js'

import { performanceService } from '@main/performance/PerformanceService.js'
import { ProcessTelemetry } from '@main/performance/ProcessTelemetry.js'
import { HEAP_SNAPSHOT_DIR, PERFORMANCE_CAPTURE_TEMP_DIR } from '@main/storage/paths.js'
import type { MonitorClearHistoryResult } from '@shared/performance/monitorHistory.js'
import type { SessionManager } from '@main/sessionManager.js'
import type {
PerformanceRecord,
SystemPerformanceStats,
} from '@shared/performance/types.js'
import type { PerformanceRecord } from '@shared/performance/types.js'

const revealablePerformancePaths = new Set<string>()

Expand All @@ -45,7 +42,6 @@ function traceStatusFor(windowId: number) {
}

export function registerPerformanceIpc(manager: SessionManager): void {
const processTelemetry = new ProcessTelemetry(manager)
monitorCoordinator.startProcesses(() => manager.getProcessTelemetryTargets())
ipcMain.handle('performance:monitor-incident', (event, id: number) => {
if (!BrowserWindow.fromWebContents(event.sender)) return null
Expand Down Expand Up @@ -141,6 +137,14 @@ export function registerPerformanceIpc(manager: SessionManager): void {
return monitorCoordinator.readProcesses(offset, sort === 'memory' ? 'memory' : 'cpu')
})

// Per-agent memory/CPU with fifteen minutes of history for the monitor's
// first page. Read-only and served from main's cached process page, so it
// never waits on the helper.
ipcMain.handle('performance:monitor-agents', event => {
if (!BrowserWindow.fromWebContents(event.sender)) return null
return monitorCoordinator.readAgentUsage(totalmem())
})

ipcMain.handle('performance:monitor-snapshot', event => {
if (!BrowserWindow.fromWebContents(event.sender)) return null
return monitorCoordinator.read()
Expand Down Expand Up @@ -181,24 +185,14 @@ export function registerPerformanceIpc(manager: SessionManager): void {

ipcMain.handle('performance:snapshot', async () => performanceService.snapshot())

ipcMain.handle('performance:pane-stats', async (_evt, sessionIds?: string[]) =>
processTelemetry.snapshot(Array.isArray(sessionIds) ? sessionIds : undefined),
)

// Keep the legacy endpoint compatible, but UI reads now share the same
// timestamped sample as the monitor and journal and never reset a window.
ipcMain.handle('performance:system-stats', (): SystemPerformanceStats => ({
...mainProbe.read(), enabled: performanceService.getConfig().enabled,
}))

// On-demand heap snapshot. Writes a .heapsnapshot file the user
// can load into Chrome DevTools' Memory tab to see retainer chains
// and per-constructor instance counts — the gold-standard
// diagnostic when the live numbers say "leak" but you need to know
// WHICH object is being retained.
//
// WHY this is a separate handler instead of folding it into the
// 1 Hz system-stats poll: writeHeapSnapshot is a multi-second
// WHY this is a separate handler instead of folding it into a poll:
// writeHeapSnapshot is a multi-second
// stop-the-world operation that produces a 100 MB-to-3 GB file. It
// is appropriate behind an explicit "user clicked Capture" gesture,
// never as a passive sample.
Expand All @@ -209,10 +203,9 @@ export function registerPerformanceIpc(manager: SessionManager): void {
// operation testable without scraping log output.
//
// WHY no AGENT_CODE_PERF gate: heap snapshots are a debugging
// escape hatch, not telemetry. If the user has the popover open
// (which already implies the flag is on, since the popover only
// renders when enabled), letting them capture is the right move
// regardless of the broader telemetry pipeline state.
// escape hatch, not telemetry. They are offered from the Performance
// Monitor, which a user investigating memory has deliberately opened;
// letting them capture is right regardless of the telemetry pipeline.
ipcMain.handle('performance:write-heap-snapshot', async (event): Promise<{
ok: true
path: string
Expand Down
87 changes: 87 additions & 0 deletions src/main/performance/AgentUsageHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import type { MonitorProcessPage, MonitorProcessRow } from '@shared/performance/processSnapshot.js'
import { AgentUsageHistory, summarizeProcessPage } from './AgentUsageHistory.js'

const row = (over: Partial<MonitorProcessRow>): MonitorProcessRow => ({
identity: `${over.pid ?? 0}:1`, pid: 1, parentPid: null, creationTime: 1, type: 'agent',
sessionIds: [], sharedSessionCount: 0, cpuPercent: 1, memoryBytes: 100, quality: 'ok', ...over,
})
const page = (at: number, rows: MonitorProcessRow[]): MonitorProcessPage => ({
summary: { sampledAt: at, count: rows.length, cpuPercent: 0, memoryBytes: 0, quality: 'ok', sessionCount: 0, missingRoots: 0, truncated: false },
rows, total: rows.length,
})

describe('agent usage attribution', () => {
it('charges sole owners, counts shared helpers once, and keeps buckets summing to the total', () => {
const { composition, sessions } = summarizeProcessPage(page(1000, [
row({ pid: 1, type: 'main', memoryBytes: 500, cpuPercent: 10 }),
row({ pid: 2, type: 'agent', provider: 'claude', sessionIds: ['a'], sharedSessionCount: 1, memoryBytes: 300, cpuPercent: 20 }),
row({ pid: 3, type: 'agent', sessionIds: ['a'], sharedSessionCount: 1, memoryBytes: 200, cpuPercent: null }),
row({ pid: 4, type: 'terminal', provider: 'terminal', sessionIds: ['t'], sharedSessionCount: 1, memoryBytes: 50, cpuPercent: 1 }),
row({ pid: 5, type: 'agent', sessionIds: ['a', 'b'], sharedSessionCount: 2, memoryBytes: 400, cpuPercent: 5 }),
row({ pid: 6, type: 'child', memoryBytes: 25, cpuPercent: 0 }),
row({ pid: null, identity: 'session:c', sessionIds: ['c'], sharedSessionCount: 1, memoryBytes: null, cpuPercent: null, quality: 'unsupported' }),
]))
expect(composition.total).toEqual({ memoryBytes: 1475, cpuPercent: 36 })
const parts = [composition.app, composition.agents, composition.terminals, composition.shared, composition.other]
expect(parts.reduce((sum, part) => sum + part.memoryBytes, 0)).toBe(composition.total.memoryBytes)
expect(sessions.get('a')).toMatchObject({ kind: 'agent', provider: 'claude', processCount: 2, memoryBytes: 500, cpuPercent: 20, complete: false })
expect(sessions.get('t')).toMatchObject({ kind: 'terminal', memoryBytes: 50 })
// A root that could not be discovered is visible but is not a process.
expect(sessions.get('c')).toMatchObject({ processCount: 0, memoryBytes: null, cpuPercent: null })
expect(sessions.has('b')).toBe(false)
})

it('keeps fifteen minutes of ordered history and ranks only sessions still running', () => {
const history = new AgentUsageHistory()
const agent = (memoryBytes: number) => row({ pid: 2, sessionIds: ['a'], sharedSessionCount: 1, memoryBytes })
history.record(page(0, [agent(100), row({ pid: 3, sessionIds: ['gone'], sharedSessionCount: 1, memoryBytes: 900 })]))
history.record(page(5000, [agent(200)]))
// A resent or clock-stepped page must not append out of order.
history.record(page(5000, [agent(999)]))
history.record(page(1000, [agent(999)]))
let usage = history.read(8 * 1024 ** 3)
expect(usage.sessions.map(session => session.sessionId)).toEqual(['a'])
expect(usage.sessions[0]!.history).toEqual([[0, 100, 1], [5000, 200, 1]])
expect(usage.systemMemoryBytes).toBe(8 * 1024 ** 3)

history.record(page(16 * 60_000, [agent(300)]))
usage = history.read(1)
expect(usage.sessions[0]!.history).toEqual([[16 * 60_000, 300, 1]])
expect(usage.composition.map(sample => sample.at)).toEqual([16 * 60_000])
})

it('caps tracked sessions at 256, evicting the oldest-seen, and re-admits an evicted live session with fresh history', () => {
const history = new AgentUsageHistory()
// 257 sole-owner sessions in one page: the 257th admission must evict the
// oldest seenAt. All rows share a timestamp, so "oldest" falls to the
// first-inserted entry — deterministic even under Map iteration order.
const rows = Array.from({ length: 257 }, (_, index) =>
row({ pid: index + 1, identity: `${index + 1}:1`, sessionIds: [`s${index}`], sharedSessionCount: 1, memoryBytes: 100 }))
history.record(page(0, rows))
let usage = history.read(1)
expect(usage.sessions).toHaveLength(256)
expect(usage.sessions.some(session => session.sessionId === 's0')).toBe(false)

// The evicted session is still running: its next page re-admits it, with
// history that starts over rather than pretending the gap was observed.
history.record(page(5000, [row({ pid: 1, identity: '1:1', sessionIds: ['s0'], sharedSessionCount: 1, memoryBytes: 100 })]))
usage = history.read(1)
expect(usage.sessions.map(session => session.sessionId)).toEqual(['s0'])
expect(usage.sessions[0]!.history).toEqual([[5000, 100, 1]])
})

// WHY this is pinned: the overview's "heaviest agent" tile and the ranking
// table both treat sessions[0] as the biggest consumer. The sort order is
// part of the IPC contract, not an accident of Map iteration.
it('ranks concurrently-live sessions by memory, descending', () => {
const history = new AgentUsageHistory()
const rows = [
row({ pid: 1, sessionIds: ['small'], sharedSessionCount: 1, memoryBytes: 100 * 1024 ** 2 }),
row({ pid: 2, sessionIds: ['large'], sharedSessionCount: 1, memoryBytes: 900 * 1024 ** 2 }),
row({ pid: 3, sessionIds: ['unread'], sharedSessionCount: 1, memoryBytes: null, cpuPercent: null }),
]
history.record(page(0, rows))
expect(history.read(1).sessions.map(session => session.sessionId)).toEqual(['large', 'small', 'unread'])
})
})
123 changes: 123 additions & 0 deletions src/main/performance/AgentUsageHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { MonitorAgentUsage, MonitorCompositionSample, MonitorSessionUsage, MonitorUsagePoint, MonitorUsageSplit } from '@shared/performance/agentUsage.js'
import type { MonitorProcessPage, MonitorProcessRow } from '@shared/performance/processSnapshot.js'
import { MONITOR_POLICY } from '@shared/performance/monitorPolicy.js'

// Process pages arrive every MONITOR_POLICY.sampleMs (5 s), so fifteen minutes
// is 180 samples. The age cut handles irregular cadence (a stalled helper); the
// count cut bounds memory even if the clock misbehaves.
const HISTORY_SAMPLES = Math.ceil(MONITOR_POLICY.recentMs / MONITOR_POLICY.sampleMs)
// A session gone for longer than the window has nothing left to chart.
// Tracking is capped so a long run that churned through thousands of short
// orchestration agents cannot grow this map without bound.
const SESSION_LIMIT = 256
const ELECTRON_TYPES = new Set<MonitorProcessRow['type']>(['main', 'renderer', 'gpu', 'utility', 'other'])

type Tracked = { usage: Omit<MonitorSessionUsage, 'history'>; history: MonitorUsagePoint[]; seenAt: number }

const split = (): MonitorUsageSplit => ({ memoryBytes: 0, cpuPercent: 0 })
function add(target: MonitorUsageSplit, row: MonitorProcessRow): void {
if (row.memoryBytes !== null) target.memoryBytes += row.memoryBytes
if (row.cpuPercent !== null) target.cpuPercent += row.cpuPercent
}

/** Attribute one process page to app / agents / terminals / shared / other and
* to individual sessions. Pure, so the attribution rules are testable without
* a helper process. See MonitorCompositionSample for why these buckets. */
export function summarizeProcessPage(page: MonitorProcessPage): { composition: MonitorCompositionSample; sessions: Map<string, Omit<MonitorSessionUsage, 'history'>> } {
const composition: MonitorCompositionSample = {
at: page.summary.sampledAt, total: split(), app: split(), agents: split(), terminals: split(), shared: split(), other: split(),
}
const sessions = new Map<string, Omit<MonitorSessionUsage, 'history'>>()
for (const row of page.rows) {
add(composition.total, row)
if (row.sharedSessionCount > 1) { add(composition.shared, row); continue }
const sessionId = row.sharedSessionCount === 1 ? row.sessionIds[0] : undefined
if (!sessionId) {
add(ELECTRON_TYPES.has(row.type) ? composition.app : composition.other, row)
continue
}
const kind = row.type === 'terminal' || row.provider === 'terminal' ? 'terminal' : 'agent'
add(kind === 'terminal' ? composition.terminals : composition.agents, row)
const entry = sessions.get(sessionId) ?? { sessionId, kind, processCount: 0, memoryBytes: null, cpuPercent: null, complete: true }
if (row.provider && !entry.provider) entry.provider = row.provider
// Children carry no provider; the root row's type is the authority.
if (row.type === 'terminal') entry.kind = 'terminal'
if (row.pid !== null) entry.processCount++
if (row.memoryBytes === null || row.cpuPercent === null) entry.complete = false
if (row.memoryBytes !== null) entry.memoryBytes = (entry.memoryBytes ?? 0) + row.memoryBytes
if (row.cpuPercent !== null) entry.cpuPercent = (entry.cpuPercent ?? 0) + row.cpuPercent
sessions.set(sessionId, entry)
}
return { composition, sessions }
}

/** Fifteen minutes of per-agent memory and CPU, kept in main next to the
* process page it is derived from.
*
* WHY main and not the renderer: the question the monitor opens with is
* "which agent has been growing", and that needs the minutes BEFORE the user
* opened the panel. WHY main and not the helper: main already holds the full
* page for UI reads, the work is one linear pass every five seconds, and
* helper query replies are capped at 256 KiB, which fifteen minutes of history
* for a large fleet would exceed. */
export class AgentUsageHistory {
private composition: MonitorCompositionSample[] = []
private sessions = new Map<string, Tracked>()
private lastAt = -Infinity
private quality: MonitorAgentUsage['quality'] = 'warming-up'

record(page: MonitorProcessPage): void {
const at = page.summary.sampledAt
// A restarted helper can resend the page already recorded. A sample from
// before the newest one is a clock step; appending it would break the
// time order every chart and delta relies on.
if (!(at > this.lastAt)) return
this.lastAt = at
this.quality = page.summary.quality
const { composition, sessions } = summarizeProcessPage(page)
this.composition.push(composition)
const cutoff = at - MONITOR_POLICY.recentMs
while (this.composition.length > HISTORY_SAMPLES || (this.composition[0] && this.composition[0].at < cutoff)) this.composition.shift()
for (const [sessionId, usage] of sessions) {
let tracked = this.sessions.get(sessionId)
if (!tracked) {
if (this.sessions.size >= SESSION_LIMIT) this.evictOldest()
tracked = { usage, history: [], seenAt: at }
this.sessions.set(sessionId, tracked)
}
tracked.usage = usage
tracked.seenAt = at
tracked.history.push([at, usage.memoryBytes, usage.cpuPercent])
while (tracked.history.length > HISTORY_SAMPLES || tracked.history[0]![0] < cutoff) tracked.history.shift()
}
for (const [sessionId, tracked] of this.sessions) if (tracked.seenAt < cutoff) this.sessions.delete(sessionId)
}

/** Only sessions present in the newest page are returned: an exited agent's
* last reading would otherwise sit in the ranking as if it still used RAM. */
read(systemMemoryBytes: number): MonitorAgentUsage {
const sessions: MonitorSessionUsage[] = []
for (const tracked of this.sessions.values()) {
if (tracked.seenAt !== this.lastAt) continue
sessions.push({ ...tracked.usage, history: tracked.history.slice() })
}
sessions.sort((a, b) => (b.memoryBytes ?? -1) - (a.memoryBytes ?? -1))
return {
sampledAt: Number.isFinite(this.lastAt) ? this.lastAt : 0, quality: this.quality, systemMemoryBytes,
composition: this.composition.slice(), sessions,
}
}

clear(): void {
this.composition = []
this.sessions.clear()
this.lastAt = -Infinity
}

private evictOldest(): void {
let oldest: string | null = null
let oldestAt = Infinity
for (const [sessionId, tracked] of this.sessions) if (tracked.seenAt < oldestAt) { oldest = sessionId; oldestAt = tracked.seenAt }
if (oldest !== null) this.sessions.delete(oldest)
}
}
Loading
Loading