@buoy-gg/mcp 7.0.21 → 7.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  SyncClient,
4
4
  createMcpServer,
5
5
  runInit
6
- } from "./chunk-JQ5GZ4LG.mjs";
6
+ } from "./chunk-SGRL6UKT.mjs";
7
7
 
8
8
  // src/cli.ts
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
package/dist/index.d.mts CHANGED
@@ -24,6 +24,14 @@ interface DeviceInfo {
24
24
  isConnected: boolean;
25
25
  /** Whether the connected app reported an active Buoy Pro license. */
26
26
  isPro: boolean;
27
+ /**
28
+ * The app's access tier. Apps older than the tier split report only `isPro`,
29
+ * which maps exactly — a key could only ever grant Pro back then.
30
+ *
31
+ * Surfaced so `list_devices` can say WHY the MCP is blocked: "signed in on
32
+ * the free tier" and "no account at all" are different problems.
33
+ */
34
+ tier: "anonymous" | "free" | "pro";
27
35
  tools: ToolCapability[];
28
36
  }
29
37
  /** How the client obtained its broker connection. */
@@ -57,11 +65,31 @@ declare class SyncClient {
57
65
  private pendingActions;
58
66
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
59
67
  private snapshotWaiters;
68
+ /** When each cached snapshot arrived, keyed by snapKey (staleness reporting). */
69
+ private snapshotAt;
70
+ /** Liveness per device — see {@link noteTimeout}. */
71
+ private health;
72
+ /** Dev-server origin per device — see {@link noteDevServerOrigin}. */
73
+ private devServerOrigins;
74
+ /** Devices whose origin probe already ran (a production bundle has none). */
75
+ private originProbed;
76
+ /** Last time each device appeared in the broker's device list. */
77
+ private lastSeen;
60
78
  constructor(opts?: SyncClientOptions);
61
79
  private readonly autoSpawn;
62
80
  /** Connect to a running broker, or spawn one in-process, then handshake. */
63
81
  start(): Promise<BrokerMode>;
64
82
  private connectSocket;
83
+ /**
84
+ * Drop everything remembered about devices that have been gone a while.
85
+ *
86
+ * Deliberately NOT on disconnect: a crashed app often drops off the broker,
87
+ * and its cached console snapshot is the crash evidence — throwing that away
88
+ * the instant it disconnects would delete the answer. But device ids change
89
+ * with every simulator erase and fresh dev client, and each one can hold
90
+ * multi-MB snapshots, so a long-lived server has to forget eventually.
91
+ */
92
+ private pruneStaleDevices;
65
93
  /** Disconnect and tear down a spawned broker, if any. */
66
94
  stop(): Promise<void>;
67
95
  /** All devices the broker knows about, merged with their advertised tools. */
@@ -91,6 +119,61 @@ declare class SyncClient {
91
119
  * is blocked.
92
120
  */
93
121
  requireProDevice(deviceId?: string): string;
122
+ private healthOf;
123
+ /** The device answered — it's alive. */
124
+ private noteOk;
125
+ /**
126
+ * The device didn't answer. A crashed app never answers again, so after the
127
+ * first miss we stop paying the full timeout on every subsequent call.
128
+ */
129
+ private noteTimeout;
130
+ /** True once a device has missed a call and hasn't answered since. */
131
+ isUnresponsive(deviceId: string): boolean;
132
+ /**
133
+ * Full timeout normally; a short one for a device already not answering.
134
+ *
135
+ * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
136
+ * full budget again. Without it the flag is self-sustaining — a device whose
137
+ * work legitimately takes longer than the short budget (a big snapshot, a
138
+ * slow simulator) can never answer, so it can never clear the flag, and every
139
+ * later reply is a confident "most likely CRASHED" about a healthy app.
140
+ */
141
+ private timeoutFor;
142
+ /**
143
+ * Plain-language diagnosis for a device that isn't answering, including the
144
+ * app's last words if the console tool got a crash flush out in time.
145
+ *
146
+ * A silent timeout is the worst possible answer here: the app is on a redbox,
147
+ * the reason is already captured, and without this the caller reads "timed
148
+ * out" and keeps retrying the same dead device.
149
+ */
150
+ unresponsiveNote(deviceId: string): string;
151
+ /**
152
+ * The newest error line the console tool synced for this device, if any.
153
+ * Reads the cached console snapshot rather than the device (which, by the
154
+ * time this matters, is not answering).
155
+ */
156
+ private lastFatalFor;
157
+ /** Age of the cached snapshot for a tool, in ms (null = never received). */
158
+ snapshotAgeMs(toolId: string, deviceId: string): number | null;
159
+ /**
160
+ * Remember where a device's bundle came from, reported by core's `app`
161
+ * adapter while the app is healthy. It is the only way to reload an app that
162
+ * has crashed: the in-app reload needs the React tree that the crash
163
+ * destroyed, but the dev server's reload is handled natively.
164
+ */
165
+ noteDevServerOrigin(deviceId: string, origin: unknown): void;
166
+ /**
167
+ * Ask a freshly-announced device where its bundle came from, once.
168
+ *
169
+ * Deliberately eager: this is the piece of information that can ONLY be
170
+ * collected while the app is healthy, and is only ever needed after it has
171
+ * stopped answering. One cheap round-trip per device buys the ability to
172
+ * recover a crashed app instead of telling the caller to go do it by hand.
173
+ */
174
+ private learnDevServerOrigin;
175
+ /** The dev-server origin last reported by this device, if any. */
176
+ devServerOrigin(deviceId: string): string | null;
94
177
  /**
95
178
  * Keep the device RECORDING this tool, without asking it to stream.
96
179
  *
@@ -135,10 +218,14 @@ declare class SyncClient {
135
218
  */
136
219
  getSnapshot(toolId: string, deviceId?: string, opts?: {
137
220
  timeoutMs?: number;
221
+ silent?: boolean;
138
222
  }): Promise<{
139
223
  deviceId: string;
140
224
  payload: unknown;
225
+ stale?: string;
141
226
  }>;
227
+ /** Header line for data served from cache because the device went quiet. */
228
+ private staleNote;
142
229
  private waitForSnapshot;
143
230
  /**
144
231
  * Invoke a remote action on a device and await its result. Pass deviceId to
@@ -146,14 +233,10 @@ declare class SyncClient {
146
233
  */
147
234
  callAction(toolId: string, action: string, params?: unknown, deviceId?: string, opts?: {
148
235
  timeoutMs?: number;
236
+ silent?: boolean;
149
237
  }): Promise<unknown>;
150
238
  }
151
239
 
152
- /**
153
- * Build the Buoy MCP server and register all tools against a connected
154
- * {@link SyncClient}. Tool packs (perf-monitor, etc.) are layered on top of the
155
- * generic core here.
156
- */
157
240
  declare function createMcpServer(sync: SyncClient): McpServer;
158
241
 
159
242
  /**
@@ -351,6 +434,12 @@ interface AutomationConfig {
351
434
  runsPerCase?: number;
352
435
  coolDownMs?: number;
353
436
  discardWarmupRuns?: number;
437
+ /**
438
+ * Run a throwaway case at the front of the batch and drop it from the
439
+ * results, so the batch's cold start doesn't land on real data. Default true.
440
+ * See the perf-monitor AutomationConfig for the reproduction.
441
+ */
442
+ discardWarmupCase?: boolean;
354
443
  shuffleCases?: boolean;
355
444
  /** Capture per-component render counts/durations each run. Default true. */
356
445
  captureRenders?: boolean;
@@ -378,13 +467,17 @@ interface IndexEntry {
378
467
  caseId?: string;
379
468
  runIndex?: number;
380
469
  isMedianRun?: boolean;
470
+ /** Thermal state at run start; a throttled run reads cheap AND janky. */
471
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
381
472
  /** Render-capture mirror (absent when capture was unavailable/disabled). */
382
473
  renderCommits?: number;
383
474
  renderTotalMs?: number;
475
+ renderWasted?: number;
384
476
  topRenderers?: {
385
477
  name: string;
386
478
  renders: number;
387
479
  totalMs: number;
480
+ cause?: string;
388
481
  }[];
389
482
  }
390
483
  /** Per-metric min/max/avg/p95 channel. */
@@ -406,12 +499,40 @@ interface BenchmarkReportStats {
406
499
  uiJankFrames: number;
407
500
  deviceMaxRefreshRate: number;
408
501
  }
409
-
410
502
  /**
411
- * Pure helpers for the perf-monitor pack: build an AutomationConfig from
412
- * MCP-friendly input, estimate batch duration, and rank a finished batch's
413
- * results into a winner + comparison table. No network/sync — unit-testable.
503
+ * Why components rendered, counted over a run.
504
+ *
505
+ * `propsUnstable` is the actionable one: props changed by IDENTITY only —
506
+ * a fresh arrow or object literal handed down every render — while every
507
+ * value stayed equal. Reporting that as plain "props" sends you into the
508
+ * child, when the fix is always in the parent.
414
509
  */
510
+ interface RenderCauseCounts {
511
+ mount?: number;
512
+ props?: number;
513
+ propsUnstable?: number;
514
+ hooks?: number;
515
+ parent?: number;
516
+ }
517
+ interface RenderCapture {
518
+ totalCommits: number;
519
+ totalRenders: number;
520
+ capturedDurations: boolean;
521
+ totalRenderMs?: number;
522
+ /** Parent cascades + identity-only prop churn: the removable share. */
523
+ wastedRenders?: number;
524
+ causes?: RenderCauseCounts;
525
+ topComponents: {
526
+ name: string;
527
+ renders: number;
528
+ totalMs: number;
529
+ maxMs: number;
530
+ avgMs: number;
531
+ causes?: RenderCauseCounts;
532
+ changedProps?: string[];
533
+ parentName?: string;
534
+ }[];
535
+ }
415
536
 
416
537
  interface BuildConfigInput {
417
538
  targetRoute: string;
@@ -423,12 +544,13 @@ interface BuildConfigInput {
423
544
  coolDownMs?: number;
424
545
  navTimeoutMs?: number;
425
546
  discardWarmupRuns?: number;
547
+ discardWarmupCase?: boolean;
426
548
  shuffleCases?: boolean;
427
549
  reloadBetweenCases?: boolean;
428
550
  captureRenders?: boolean;
429
551
  }
430
552
  /** The settings subset a driver inherits from the device's saved config. */
431
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
553
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "discardWarmupCase" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
432
554
  /**
433
555
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
434
556
  *
@@ -461,13 +583,17 @@ interface RankRow {
461
583
  deltaJsFps: number | null;
462
584
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
463
585
  flags: string[];
586
+ /** Thermal state at the median run's start, when the device reported one. */
587
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
464
588
  /** React commits during the median run (null when capture was off/unavailable). */
465
589
  renderCommits: number | null;
466
590
  /** Top components by total render self-time during the median run. */
591
+ renderWasted?: number;
467
592
  topRenderers: {
468
593
  name: string;
469
594
  renders: number;
470
595
  totalMs: number;
596
+ cause?: string;
471
597
  }[];
472
598
  }
473
599
  interface BatchRanking {
@@ -506,14 +632,44 @@ declare function rankBatchFromIndex(index: IndexEntry[], batchId: string, casesO
506
632
  }[]): BatchRanking;
507
633
  /** Render a batch ranking as a trade-off-aware report (no single false winner). */
508
634
  declare function formatRankingMarkdown(ranking: BatchRanking): string;
509
- /** Side-by-side comparison of two full report stats. */
635
+ /**
636
+ * Side-by-side comparison of two full reports.
637
+ *
638
+ * Renders are part of the comparison, not an afterthought: when someone runs
639
+ * this after a memoization fix, "did the render count actually drop" IS the
640
+ * question, and for a long while this function silently dropped the block
641
+ * that answers it.
642
+ */
510
643
  declare function compareReportStats(a: {
511
644
  name: string;
512
645
  stats: BenchmarkReportStats;
646
+ renders?: RenderCapture;
513
647
  }, b: {
514
648
  name: string;
515
649
  stats: BenchmarkReportStats;
650
+ renders?: RenderCapture;
516
651
  }): {
652
+ renders?: {
653
+ total: {
654
+ a: number;
655
+ b: number;
656
+ delta: number;
657
+ };
658
+ commits: {
659
+ a: number;
660
+ b: number;
661
+ delta: number;
662
+ };
663
+ wasted: {
664
+ a: number;
665
+ b: number;
666
+ delta: number;
667
+ };
668
+ causes: {
669
+ a: RenderCauseCounts;
670
+ b: RenderCauseCounts;
671
+ };
672
+ } | undefined;
517
673
  a: string;
518
674
  b: string;
519
675
  uiFps: {
package/dist/index.d.ts CHANGED
@@ -24,6 +24,14 @@ interface DeviceInfo {
24
24
  isConnected: boolean;
25
25
  /** Whether the connected app reported an active Buoy Pro license. */
26
26
  isPro: boolean;
27
+ /**
28
+ * The app's access tier. Apps older than the tier split report only `isPro`,
29
+ * which maps exactly — a key could only ever grant Pro back then.
30
+ *
31
+ * Surfaced so `list_devices` can say WHY the MCP is blocked: "signed in on
32
+ * the free tier" and "no account at all" are different problems.
33
+ */
34
+ tier: "anonymous" | "free" | "pro";
27
35
  tools: ToolCapability[];
28
36
  }
29
37
  /** How the client obtained its broker connection. */
@@ -57,11 +65,31 @@ declare class SyncClient {
57
65
  private pendingActions;
58
66
  /** Callbacks waiting for the next snapshot of `${deviceId}::${toolId}`. */
59
67
  private snapshotWaiters;
68
+ /** When each cached snapshot arrived, keyed by snapKey (staleness reporting). */
69
+ private snapshotAt;
70
+ /** Liveness per device — see {@link noteTimeout}. */
71
+ private health;
72
+ /** Dev-server origin per device — see {@link noteDevServerOrigin}. */
73
+ private devServerOrigins;
74
+ /** Devices whose origin probe already ran (a production bundle has none). */
75
+ private originProbed;
76
+ /** Last time each device appeared in the broker's device list. */
77
+ private lastSeen;
60
78
  constructor(opts?: SyncClientOptions);
61
79
  private readonly autoSpawn;
62
80
  /** Connect to a running broker, or spawn one in-process, then handshake. */
63
81
  start(): Promise<BrokerMode>;
64
82
  private connectSocket;
83
+ /**
84
+ * Drop everything remembered about devices that have been gone a while.
85
+ *
86
+ * Deliberately NOT on disconnect: a crashed app often drops off the broker,
87
+ * and its cached console snapshot is the crash evidence — throwing that away
88
+ * the instant it disconnects would delete the answer. But device ids change
89
+ * with every simulator erase and fresh dev client, and each one can hold
90
+ * multi-MB snapshots, so a long-lived server has to forget eventually.
91
+ */
92
+ private pruneStaleDevices;
65
93
  /** Disconnect and tear down a spawned broker, if any. */
66
94
  stop(): Promise<void>;
67
95
  /** All devices the broker knows about, merged with their advertised tools. */
@@ -91,6 +119,61 @@ declare class SyncClient {
91
119
  * is blocked.
92
120
  */
93
121
  requireProDevice(deviceId?: string): string;
122
+ private healthOf;
123
+ /** The device answered — it's alive. */
124
+ private noteOk;
125
+ /**
126
+ * The device didn't answer. A crashed app never answers again, so after the
127
+ * first miss we stop paying the full timeout on every subsequent call.
128
+ */
129
+ private noteTimeout;
130
+ /** True once a device has missed a call and hasn't answered since. */
131
+ isUnresponsive(deviceId: string): boolean;
132
+ /**
133
+ * Full timeout normally; a short one for a device already not answering.
134
+ *
135
+ * With one escape hatch: every RECOVERY_PROBE_MS a flagged device gets the
136
+ * full budget again. Without it the flag is self-sustaining — a device whose
137
+ * work legitimately takes longer than the short budget (a big snapshot, a
138
+ * slow simulator) can never answer, so it can never clear the flag, and every
139
+ * later reply is a confident "most likely CRASHED" about a healthy app.
140
+ */
141
+ private timeoutFor;
142
+ /**
143
+ * Plain-language diagnosis for a device that isn't answering, including the
144
+ * app's last words if the console tool got a crash flush out in time.
145
+ *
146
+ * A silent timeout is the worst possible answer here: the app is on a redbox,
147
+ * the reason is already captured, and without this the caller reads "timed
148
+ * out" and keeps retrying the same dead device.
149
+ */
150
+ unresponsiveNote(deviceId: string): string;
151
+ /**
152
+ * The newest error line the console tool synced for this device, if any.
153
+ * Reads the cached console snapshot rather than the device (which, by the
154
+ * time this matters, is not answering).
155
+ */
156
+ private lastFatalFor;
157
+ /** Age of the cached snapshot for a tool, in ms (null = never received). */
158
+ snapshotAgeMs(toolId: string, deviceId: string): number | null;
159
+ /**
160
+ * Remember where a device's bundle came from, reported by core's `app`
161
+ * adapter while the app is healthy. It is the only way to reload an app that
162
+ * has crashed: the in-app reload needs the React tree that the crash
163
+ * destroyed, but the dev server's reload is handled natively.
164
+ */
165
+ noteDevServerOrigin(deviceId: string, origin: unknown): void;
166
+ /**
167
+ * Ask a freshly-announced device where its bundle came from, once.
168
+ *
169
+ * Deliberately eager: this is the piece of information that can ONLY be
170
+ * collected while the app is healthy, and is only ever needed after it has
171
+ * stopped answering. One cheap round-trip per device buys the ability to
172
+ * recover a crashed app instead of telling the caller to go do it by hand.
173
+ */
174
+ private learnDevServerOrigin;
175
+ /** The dev-server origin last reported by this device, if any. */
176
+ devServerOrigin(deviceId: string): string | null;
94
177
  /**
95
178
  * Keep the device RECORDING this tool, without asking it to stream.
96
179
  *
@@ -135,10 +218,14 @@ declare class SyncClient {
135
218
  */
136
219
  getSnapshot(toolId: string, deviceId?: string, opts?: {
137
220
  timeoutMs?: number;
221
+ silent?: boolean;
138
222
  }): Promise<{
139
223
  deviceId: string;
140
224
  payload: unknown;
225
+ stale?: string;
141
226
  }>;
227
+ /** Header line for data served from cache because the device went quiet. */
228
+ private staleNote;
142
229
  private waitForSnapshot;
143
230
  /**
144
231
  * Invoke a remote action on a device and await its result. Pass deviceId to
@@ -146,14 +233,10 @@ declare class SyncClient {
146
233
  */
147
234
  callAction(toolId: string, action: string, params?: unknown, deviceId?: string, opts?: {
148
235
  timeoutMs?: number;
236
+ silent?: boolean;
149
237
  }): Promise<unknown>;
150
238
  }
151
239
 
152
- /**
153
- * Build the Buoy MCP server and register all tools against a connected
154
- * {@link SyncClient}. Tool packs (perf-monitor, etc.) are layered on top of the
155
- * generic core here.
156
- */
157
240
  declare function createMcpServer(sync: SyncClient): McpServer;
158
241
 
159
242
  /**
@@ -351,6 +434,12 @@ interface AutomationConfig {
351
434
  runsPerCase?: number;
352
435
  coolDownMs?: number;
353
436
  discardWarmupRuns?: number;
437
+ /**
438
+ * Run a throwaway case at the front of the batch and drop it from the
439
+ * results, so the batch's cold start doesn't land on real data. Default true.
440
+ * See the perf-monitor AutomationConfig for the reproduction.
441
+ */
442
+ discardWarmupCase?: boolean;
354
443
  shuffleCases?: boolean;
355
444
  /** Capture per-component render counts/durations each run. Default true. */
356
445
  captureRenders?: boolean;
@@ -378,13 +467,17 @@ interface IndexEntry {
378
467
  caseId?: string;
379
468
  runIndex?: number;
380
469
  isMedianRun?: boolean;
470
+ /** Thermal state at run start; a throttled run reads cheap AND janky. */
471
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
381
472
  /** Render-capture mirror (absent when capture was unavailable/disabled). */
382
473
  renderCommits?: number;
383
474
  renderTotalMs?: number;
475
+ renderWasted?: number;
384
476
  topRenderers?: {
385
477
  name: string;
386
478
  renders: number;
387
479
  totalMs: number;
480
+ cause?: string;
388
481
  }[];
389
482
  }
390
483
  /** Per-metric min/max/avg/p95 channel. */
@@ -406,12 +499,40 @@ interface BenchmarkReportStats {
406
499
  uiJankFrames: number;
407
500
  deviceMaxRefreshRate: number;
408
501
  }
409
-
410
502
  /**
411
- * Pure helpers for the perf-monitor pack: build an AutomationConfig from
412
- * MCP-friendly input, estimate batch duration, and rank a finished batch's
413
- * results into a winner + comparison table. No network/sync — unit-testable.
503
+ * Why components rendered, counted over a run.
504
+ *
505
+ * `propsUnstable` is the actionable one: props changed by IDENTITY only —
506
+ * a fresh arrow or object literal handed down every render — while every
507
+ * value stayed equal. Reporting that as plain "props" sends you into the
508
+ * child, when the fix is always in the parent.
414
509
  */
510
+ interface RenderCauseCounts {
511
+ mount?: number;
512
+ props?: number;
513
+ propsUnstable?: number;
514
+ hooks?: number;
515
+ parent?: number;
516
+ }
517
+ interface RenderCapture {
518
+ totalCommits: number;
519
+ totalRenders: number;
520
+ capturedDurations: boolean;
521
+ totalRenderMs?: number;
522
+ /** Parent cascades + identity-only prop churn: the removable share. */
523
+ wastedRenders?: number;
524
+ causes?: RenderCauseCounts;
525
+ topComponents: {
526
+ name: string;
527
+ renders: number;
528
+ totalMs: number;
529
+ maxMs: number;
530
+ avgMs: number;
531
+ causes?: RenderCauseCounts;
532
+ changedProps?: string[];
533
+ parentName?: string;
534
+ }[];
535
+ }
415
536
 
416
537
  interface BuildConfigInput {
417
538
  targetRoute: string;
@@ -423,12 +544,13 @@ interface BuildConfigInput {
423
544
  coolDownMs?: number;
424
545
  navTimeoutMs?: number;
425
546
  discardWarmupRuns?: number;
547
+ discardWarmupCase?: boolean;
426
548
  shuffleCases?: boolean;
427
549
  reloadBetweenCases?: boolean;
428
550
  captureRenders?: boolean;
429
551
  }
430
552
  /** The settings subset a driver inherits from the device's saved config. */
431
- type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
553
+ type BenchmarkSettings = Partial<Pick<AutomationConfig, "perCaseDurationMs" | "settleMs" | "navTimeoutMs" | "runsPerCase" | "coolDownMs" | "discardWarmupRuns" | "discardWarmupCase" | "shuffleCases" | "reloadBetweenCases" | "captureRenders">>;
432
554
  /**
433
555
  * Build + validate the AutomationConfig the device expects. Throws on bad input.
434
556
  *
@@ -461,13 +583,17 @@ interface RankRow {
461
583
  deltaJsFps: number | null;
462
584
  /** At-risk axes for this case (e.g. "UI thread drops frames", "CPU near cap"). */
463
585
  flags: string[];
586
+ /** Thermal state at the median run's start, when the device reported one. */
587
+ thermalState?: "nominal" | "fair" | "serious" | "critical";
464
588
  /** React commits during the median run (null when capture was off/unavailable). */
465
589
  renderCommits: number | null;
466
590
  /** Top components by total render self-time during the median run. */
591
+ renderWasted?: number;
467
592
  topRenderers: {
468
593
  name: string;
469
594
  renders: number;
470
595
  totalMs: number;
596
+ cause?: string;
471
597
  }[];
472
598
  }
473
599
  interface BatchRanking {
@@ -506,14 +632,44 @@ declare function rankBatchFromIndex(index: IndexEntry[], batchId: string, casesO
506
632
  }[]): BatchRanking;
507
633
  /** Render a batch ranking as a trade-off-aware report (no single false winner). */
508
634
  declare function formatRankingMarkdown(ranking: BatchRanking): string;
509
- /** Side-by-side comparison of two full report stats. */
635
+ /**
636
+ * Side-by-side comparison of two full reports.
637
+ *
638
+ * Renders are part of the comparison, not an afterthought: when someone runs
639
+ * this after a memoization fix, "did the render count actually drop" IS the
640
+ * question, and for a long while this function silently dropped the block
641
+ * that answers it.
642
+ */
510
643
  declare function compareReportStats(a: {
511
644
  name: string;
512
645
  stats: BenchmarkReportStats;
646
+ renders?: RenderCapture;
513
647
  }, b: {
514
648
  name: string;
515
649
  stats: BenchmarkReportStats;
650
+ renders?: RenderCapture;
516
651
  }): {
652
+ renders?: {
653
+ total: {
654
+ a: number;
655
+ b: number;
656
+ delta: number;
657
+ };
658
+ commits: {
659
+ a: number;
660
+ b: number;
661
+ delta: number;
662
+ };
663
+ wasted: {
664
+ a: number;
665
+ b: number;
666
+ delta: number;
667
+ };
668
+ causes: {
669
+ a: RenderCauseCounts;
670
+ b: RenderCauseCounts;
671
+ };
672
+ } | undefined;
517
673
  a: string;
518
674
  b: string;
519
675
  uiFps: {