@flrande/bak-extension 0.6.16 → 0.6.17

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/src/background.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import type {
2
2
  ConsoleEntry,
3
+ FetchDiagnostics,
3
4
  DebugDumpSection,
4
5
  InspectFreshnessResult,
5
6
  InspectLiveUpdatesResult,
6
7
  InspectPageDataCandidateProbe,
8
+ InspectPageDateControl,
7
9
  InspectPageDataResult,
10
+ InspectPageModeGroup,
8
11
  Locator,
12
+ NetworkCloneResult,
9
13
  NetworkEntry,
10
14
  PageExecutionScope,
11
15
  PageFetchResponse,
@@ -153,6 +157,8 @@ interface PageInspectionState {
153
157
  inlineTimestampCandidates?: Array<Record<string, unknown>>;
154
158
  tables?: TableHandle[];
155
159
  inlineJsonSources?: InlineJsonInspectionSource[];
160
+ modeGroups?: InspectPageModeGroup[];
161
+ dateControls?: InspectPageDateControl[];
156
162
  cookies?: Array<{ name: string }>;
157
163
  lastMutationAt?: number;
158
164
  pageLoadedAt?: number;
@@ -174,18 +180,23 @@ interface PageInspectionState {
174
180
  intervals: number;
175
181
  };
176
182
  }
177
- const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
178
- 'accept-encoding',
179
- 'authorization',
180
- 'connection',
183
+ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
184
+ 'accept-encoding',
185
+ 'authorization',
186
+ 'connection',
181
187
  'content-length',
182
188
  'cookie',
183
189
  'host',
184
190
  'origin',
185
191
  'proxy-authorization',
186
192
  'referer',
187
- 'set-cookie'
188
- ]);
193
+ 'set-cookie'
194
+ ]);
195
+ const CLONE_FORBIDDEN_HEADER_NAMES = new Set([
196
+ ...REPLAY_FORBIDDEN_HEADER_NAMES,
197
+ 'x-csrf-token',
198
+ 'x-xsrf-token'
199
+ ]);
189
200
 
190
201
  let ws: WebSocket | null = null;
191
202
  let reconnectTimer: number | null = null;
@@ -248,7 +259,7 @@ function sendResponse(payload: CliResponse): void {
248
259
  }
249
260
  }
250
261
 
251
- function sendEvent(event: string, data: Record<string, unknown>): void {
262
+ function sendEvent(event: string, data: Record<string, unknown>): void {
252
263
  if (ws && ws.readyState === WebSocket.OPEN) {
253
264
  ws.send(
254
265
  JSON.stringify({
@@ -351,6 +362,14 @@ async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
351
362
  return Object.values(await loadSessionBindingStateMap());
352
363
  }
353
364
 
365
+ function quotePowerShellArg(value: string): string {
366
+ return `'${value.replace(/'/g, "''")}'`;
367
+ }
368
+
369
+ function renderPowerShellCommand(argv: string[]): string {
370
+ return ['bak', ...argv].map((part) => quotePowerShellArg(part)).join(' ');
371
+ }
372
+
354
373
  function collectPopupSessionBindingTabIds(state: SessionBindingRecord): number[] {
355
374
  return [
356
375
  ...new Set(state.tabIds.concat([state.activeTabId, state.primaryTabId].filter((value): value is number => typeof value === 'number')))
@@ -1254,6 +1273,57 @@ async function executePageWorld<T>(
1254
1273
  return {};
1255
1274
  };
1256
1275
 
1276
+ const utf8ByteLength = (value: string): number => {
1277
+ if (typeof TextEncoder === 'function') {
1278
+ return new TextEncoder().encode(value).byteLength;
1279
+ }
1280
+ return value.length;
1281
+ };
1282
+
1283
+ const truncateUtf8Text = (value: string, limit: number): string => {
1284
+ if (limit <= 0) {
1285
+ return '';
1286
+ }
1287
+ if (typeof TextEncoder !== 'function' || typeof TextDecoder !== 'function') {
1288
+ return value.slice(0, limit);
1289
+ }
1290
+ const encoded = new TextEncoder().encode(value);
1291
+ if (encoded.byteLength <= limit) {
1292
+ return value;
1293
+ }
1294
+ return new TextDecoder().decode(encoded.subarray(0, limit));
1295
+ };
1296
+
1297
+ const buildRetryHints = (requestUrl: string, baseUrl: string): string[] => {
1298
+ const hints: string[] = [];
1299
+ let parsed: URL | null = null;
1300
+ try {
1301
+ parsed = new URL(requestUrl, baseUrl);
1302
+ } catch {
1303
+ parsed = null;
1304
+ }
1305
+ const keys = (() => {
1306
+ if (!parsed) {
1307
+ return [];
1308
+ }
1309
+ const collected: string[] = [];
1310
+ parsed.searchParams.forEach((_value, key) => {
1311
+ collected.push(key.toLowerCase());
1312
+ });
1313
+ return [...new Set(collected)];
1314
+ })();
1315
+ if (keys.some((key) => key.includes('limit'))) {
1316
+ hints.push('reduce the limit parameter and retry');
1317
+ }
1318
+ if (keys.some((key) => /(from|to|start|end|date|time|timestamp)/i.test(key))) {
1319
+ hints.push('narrow the requested time window and retry');
1320
+ }
1321
+ if (keys.some((key) => key.includes('page')) && keys.some((key) => key.includes('limit'))) {
1322
+ hints.push('retry with smaller paginated windows');
1323
+ }
1324
+ return hints;
1325
+ };
1326
+
1257
1327
  try {
1258
1328
  const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
1259
1329
  if (payload.action === 'eval') {
@@ -1316,46 +1386,143 @@ async function executePageWorld<T>(
1316
1386
  }
1317
1387
  }
1318
1388
  }
1389
+
1390
+ const requestHints = buildRetryHints(payload.url, targetWindow.location.href);
1391
+ const diagnosticsBase = {
1392
+ requestSent: false,
1393
+ responseStarted: false,
1394
+ status: undefined as number | undefined,
1395
+ headersReceived: {} as Record<string, string>,
1396
+ bodyBytesRead: 0,
1397
+ partialBodyPreview: '',
1398
+ timing: {
1399
+ startedAt: Date.now()
1400
+ } as FetchDiagnostics['timing']
1401
+ };
1402
+ const previewLimit =
1403
+ !fullResponse && typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
1404
+ let previewBytes = 0;
1405
+ const appendPreviewText = (value: string): void => {
1406
+ if (!value || previewBytes >= previewLimit) {
1407
+ return;
1408
+ }
1409
+ const remaining = previewLimit - previewBytes;
1410
+ const next = truncateUtf8Text(value, remaining);
1411
+ diagnosticsBase.partialBodyPreview += next;
1412
+ previewBytes += utf8ByteLength(next);
1413
+ };
1414
+
1319
1415
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1320
1416
  const timeoutId =
1321
- controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
1322
- ? window.setTimeout(() => controller.abort(), payload.timeoutMs)
1323
- : null;
1324
- let response: Response;
1325
- try {
1326
- response = await targetWindow.fetch(payload.url, {
1327
- method: payload.method || 'GET',
1328
- headers,
1329
- body: typeof payload.body === 'string' ? payload.body : undefined,
1330
- signal: controller ? controller.signal : undefined
1331
- });
1332
- } finally {
1333
- if (timeoutId !== null) {
1334
- window.clearTimeout(timeoutId);
1335
- }
1336
- }
1337
- const bodyText = await response.text();
1338
- const headerMap: Record<string, string> = {};
1339
- response.headers.forEach((value, key) => {
1340
- headerMap[key] = value;
1341
- });
1342
- return {
1343
- url: targetWindow.location.href,
1344
- framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
1345
- value: (() => {
1346
- const encoder = typeof TextEncoder === 'function' ? new TextEncoder() : null;
1417
+ controller && typeof payload.timeoutMs === 'number' && payload.timeoutMs > 0
1418
+ ? window.setTimeout(() => controller.abort(), payload.timeoutMs)
1419
+ : null;
1420
+ let response: Response;
1421
+ let bodyText = '';
1422
+ try {
1423
+ diagnosticsBase.requestSent = true;
1424
+ diagnosticsBase.timing.requestSentAt = Date.now();
1425
+ response = await targetWindow.fetch(payload.url, {
1426
+ method: payload.method || 'GET',
1427
+ headers,
1428
+ body: typeof payload.body === 'string' ? payload.body : undefined,
1429
+ signal: controller ? controller.signal : undefined
1430
+ });
1431
+ diagnosticsBase.responseStarted = true;
1432
+ diagnosticsBase.timing.responseStartedAt = Date.now();
1433
+ diagnosticsBase.status = response.status;
1434
+ response.headers.forEach((value, key) => {
1435
+ diagnosticsBase.headersReceived[key] = value;
1436
+ });
1437
+
1438
+ const reader = response.body?.getReader?.();
1439
+ if (reader) {
1347
1440
  const decoder = typeof TextDecoder === 'function' ? new TextDecoder() : null;
1348
- const previewLimit = !fullResponse && typeof payload.maxBytes === 'number' && payload.maxBytes > 0 ? payload.maxBytes : 8192;
1349
- const encodedBody = encoder ? encoder.encode(bodyText) : null;
1350
- const bodyBytes = encodedBody ? encodedBody.byteLength : bodyText.length;
1441
+ while (true) {
1442
+ const chunk = await reader.read();
1443
+ if (chunk.done) {
1444
+ break;
1445
+ }
1446
+ const value = chunk.value ?? new Uint8Array();
1447
+ diagnosticsBase.bodyBytesRead += value.byteLength;
1448
+ if (decoder) {
1449
+ const decoded = decoder.decode(value, { stream: true });
1450
+ bodyText += decoded;
1451
+ appendPreviewText(decoded);
1452
+ } else {
1453
+ const fallback = String.fromCharCode(...value);
1454
+ bodyText += fallback;
1455
+ appendPreviewText(fallback);
1456
+ }
1457
+ }
1458
+ if (decoder) {
1459
+ const flushed = decoder.decode();
1460
+ if (flushed) {
1461
+ bodyText += flushed;
1462
+ appendPreviewText(flushed);
1463
+ }
1464
+ }
1465
+ } else {
1466
+ bodyText = await response.text();
1467
+ diagnosticsBase.bodyBytesRead = utf8ByteLength(bodyText);
1468
+ appendPreviewText(bodyText);
1469
+ }
1470
+ diagnosticsBase.timing.completedAt = Date.now();
1471
+ } catch (error) {
1472
+ const abortLike =
1473
+ (error instanceof DOMException && error.name === 'AbortError') ||
1474
+ (error instanceof Error && /abort|timeout/i.test(error.message));
1475
+ const where: FetchDiagnostics['where'] =
1476
+ diagnosticsBase.requestSent !== true
1477
+ ? 'dispatch'
1478
+ : diagnosticsBase.responseStarted !== true
1479
+ ? 'ttfb'
1480
+ : 'body';
1481
+ const diagnostics: FetchDiagnostics = {
1482
+ kind: abortLike ? 'timeout' : 'network',
1483
+ retryable: true,
1484
+ where,
1485
+ timing: {
1486
+ ...diagnosticsBase.timing,
1487
+ ...(abortLike ? { timeoutAt: Date.now() } : {})
1488
+ },
1489
+ requestSent: diagnosticsBase.requestSent,
1490
+ responseStarted: diagnosticsBase.responseStarted,
1491
+ status: diagnosticsBase.status,
1492
+ headersReceived:
1493
+ Object.keys(diagnosticsBase.headersReceived).length > 0 ? diagnosticsBase.headersReceived : undefined,
1494
+ bodyBytesRead: diagnosticsBase.bodyBytesRead,
1495
+ partialBodyPreview: diagnosticsBase.partialBodyPreview || undefined,
1496
+ hints: requestHints
1497
+ };
1498
+ throw {
1499
+ code: abortLike ? 'E_TIMEOUT' : 'E_EXECUTION',
1500
+ message: abortLike ? `page.fetch timeout during ${where}` : error instanceof Error ? error.message : String(error),
1501
+ details: {
1502
+ ...diagnostics,
1503
+ diagnostics
1504
+ }
1505
+ };
1506
+ } finally {
1507
+ if (timeoutId !== null) {
1508
+ window.clearTimeout(timeoutId);
1509
+ }
1510
+ }
1511
+ const headerMap: Record<string, string> = {};
1512
+ response.headers.forEach((value, key) => {
1513
+ headerMap[key] = value;
1514
+ });
1515
+ return {
1516
+ url: targetWindow.location.href,
1517
+ framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
1518
+ value: (() => {
1519
+ const bodyBytes = diagnosticsBase.bodyBytesRead || utf8ByteLength(bodyText);
1351
1520
  const truncated = !fullResponse && bodyBytes > previewLimit;
1352
1521
  const previewText =
1353
1522
  fullResponse
1354
1523
  ? bodyText
1355
- : encodedBody && decoder
1356
- ? decoder.decode(encodedBody.subarray(0, Math.min(encodedBody.byteLength, previewLimit)))
1357
1524
  : truncated
1358
- ? bodyText.slice(0, previewLimit)
1525
+ ? truncateUtf8Text(bodyText, previewLimit)
1359
1526
  : bodyText;
1360
1527
  const result: Record<string, unknown> = {
1361
1528
  url: response.url,
@@ -1366,10 +1533,57 @@ async function executePageWorld<T>(
1366
1533
  bytes: bodyBytes,
1367
1534
  truncated,
1368
1535
  authApplied: authApplied.length > 0 ? authApplied : undefined,
1369
- authSources: authSources.size > 0 ? [...authSources] : undefined
1536
+ authSources: authSources.size > 0 ? [...authSources] : undefined,
1537
+ diagnostics: {
1538
+ kind: 'success',
1539
+ retryable: false,
1540
+ where: 'complete',
1541
+ timing: diagnosticsBase.timing,
1542
+ requestSent: diagnosticsBase.requestSent,
1543
+ responseStarted: diagnosticsBase.responseStarted,
1544
+ status: response.status,
1545
+ headersReceived: headerMap,
1546
+ bodyBytesRead: bodyBytes,
1547
+ partialBodyPreview: diagnosticsBase.partialBodyPreview || undefined,
1548
+ hints: requestHints
1549
+ } satisfies FetchDiagnostics
1370
1550
  };
1371
1551
  if (payload.mode === 'json') {
1372
- const parsedJson = bodyText ? JSON.parse(bodyText) : undefined;
1552
+ let parsedJson: unknown;
1553
+ try {
1554
+ parsedJson = bodyText ? JSON.parse(bodyText) : undefined;
1555
+ } catch (error) {
1556
+ throw {
1557
+ code: 'E_EXECUTION',
1558
+ message: error instanceof Error ? error.message : String(error),
1559
+ details: {
1560
+ kind: 'execution',
1561
+ retryable: false,
1562
+ where: 'complete',
1563
+ timing: diagnosticsBase.timing,
1564
+ requestSent: diagnosticsBase.requestSent,
1565
+ responseStarted: diagnosticsBase.responseStarted,
1566
+ status: response.status,
1567
+ headersReceived: headerMap,
1568
+ bodyBytesRead: bodyBytes,
1569
+ partialBodyPreview: diagnosticsBase.partialBodyPreview || undefined,
1570
+ hints: requestHints,
1571
+ diagnostics: {
1572
+ kind: 'execution',
1573
+ retryable: false,
1574
+ where: 'complete',
1575
+ timing: diagnosticsBase.timing,
1576
+ requestSent: diagnosticsBase.requestSent,
1577
+ responseStarted: diagnosticsBase.responseStarted,
1578
+ status: response.status,
1579
+ headersReceived: headerMap,
1580
+ bodyBytesRead: bodyBytes,
1581
+ partialBodyPreview: diagnosticsBase.partialBodyPreview || undefined,
1582
+ hints: requestHints
1583
+ } satisfies FetchDiagnostics
1584
+ }
1585
+ };
1586
+ }
1373
1587
  const summary = buildJsonSummary(parsedJson);
1374
1588
  if (fullResponse || !truncated) {
1375
1589
  result.json = parsedJson;
@@ -1444,10 +1658,10 @@ function truncateNetworkEntry(entry: NetworkEntry, bodyBytes?: number): NetworkE
1444
1658
  return clone;
1445
1659
  }
1446
1660
 
1447
- function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
1448
- if (!Array.isArray(include)) {
1449
- return entry;
1450
- }
1661
+ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
1662
+ if (!Array.isArray(include)) {
1663
+ return entry;
1664
+ }
1451
1665
  const sections = new Set(
1452
1666
  include
1453
1667
  .map(String)
@@ -1456,20 +1670,34 @@ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): Netw
1456
1670
  if (sections.size === 0 || sections.size === 2) {
1457
1671
  return entry;
1458
1672
  }
1459
- const clone: NetworkEntry = { ...entry };
1460
- if (!sections.has('request')) {
1461
- delete clone.requestHeaders;
1462
- delete clone.requestBodyPreview;
1463
- delete clone.requestBodyTruncated;
1464
- }
1465
- if (!sections.has('response')) {
1466
- delete clone.responseHeaders;
1467
- delete clone.responseBodyPreview;
1468
- delete clone.responseBodyTruncated;
1469
- delete clone.binary;
1470
- }
1471
- return clone;
1472
- }
1673
+ const clone: NetworkEntry = { ...entry };
1674
+ if (!sections.has('request')) {
1675
+ delete clone.requestHeaders;
1676
+ delete clone.requestBodyPreview;
1677
+ delete clone.requestBodyTruncated;
1678
+ if (clone.preview) {
1679
+ clone.preview = { ...clone.preview };
1680
+ delete clone.preview.request;
1681
+ if (!clone.preview.query && !clone.preview.request && !clone.preview.response) {
1682
+ delete clone.preview;
1683
+ }
1684
+ }
1685
+ }
1686
+ if (!sections.has('response')) {
1687
+ delete clone.responseHeaders;
1688
+ delete clone.responseBodyPreview;
1689
+ delete clone.responseBodyTruncated;
1690
+ delete clone.binary;
1691
+ if (clone.preview) {
1692
+ clone.preview = { ...clone.preview };
1693
+ delete clone.preview.response;
1694
+ if (!clone.preview.query && !clone.preview.request && !clone.preview.response) {
1695
+ delete clone.preview;
1696
+ }
1697
+ }
1698
+ }
1699
+ return clone;
1700
+ }
1473
1701
 
1474
1702
  function replayHeadersFromRequestHeaders(requestHeaders: Record<string, string> | undefined): Record<string, string> | undefined {
1475
1703
  if (!requestHeaders) {
@@ -1485,6 +1713,121 @@ function replayHeadersFromRequestHeaders(requestHeaders: Record<string, string>
1485
1713
  }
1486
1714
  return Object.keys(headers).length > 0 ? headers : undefined;
1487
1715
  }
1716
+
1717
+ function cloneHeadersFromRequestHeaders(requestHeaders: Record<string, string> | undefined): {
1718
+ headers?: Record<string, string>;
1719
+ omitted: string[];
1720
+ } {
1721
+ if (!requestHeaders) {
1722
+ return { omitted: [] };
1723
+ }
1724
+ const headers: Record<string, string> = {};
1725
+ const omitted = new Set<string>();
1726
+ for (const [name, value] of Object.entries(requestHeaders)) {
1727
+ const normalizedName = name.toLowerCase();
1728
+ if (CLONE_FORBIDDEN_HEADER_NAMES.has(normalizedName) || normalizedName.startsWith('sec-')) {
1729
+ omitted.add(normalizedName);
1730
+ continue;
1731
+ }
1732
+ headers[name] = value;
1733
+ }
1734
+ return {
1735
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
1736
+ omitted: [...omitted].sort()
1737
+ };
1738
+ }
1739
+
1740
+ function isSameOriginWithTab(tabUrl: string | undefined, requestUrl: string): boolean {
1741
+ try {
1742
+ if (!tabUrl) {
1743
+ return false;
1744
+ }
1745
+ return new URL(requestUrl).origin === new URL(tabUrl).origin;
1746
+ } catch {
1747
+ return false;
1748
+ }
1749
+ }
1750
+
1751
+ function buildNetworkCloneResult(
1752
+ requestId: string,
1753
+ tabUrl: string | undefined,
1754
+ replayable: NonNullable<ReturnType<typeof getReplayableNetworkRequest>>
1755
+ ): NetworkCloneResult {
1756
+ const sameOrigin = isSameOriginWithTab(tabUrl, replayable.entry.url);
1757
+ const cloneable = sameOrigin && replayable.bodyTruncated !== true;
1758
+ const notes: string[] = [];
1759
+ const sanitizedHeaders = cloneHeadersFromRequestHeaders(replayable.headers);
1760
+ if (sanitizedHeaders.omitted.length > 0) {
1761
+ notes.push(`omitted sensitive headers: ${sanitizedHeaders.omitted.join(', ')}`);
1762
+ }
1763
+ if (!sameOrigin) {
1764
+ notes.push('preferred page.fetch template was skipped because the captured request is not same-origin with the current page');
1765
+ }
1766
+ if (replayable.bodyTruncated) {
1767
+ notes.push('captured request body was truncated, so bak cannot emit a reliable page.fetch clone');
1768
+ }
1769
+ if (!sameOrigin || replayable.bodyTruncated) {
1770
+ notes.push('falling back to network.replay preserves the captured request shape more safely');
1771
+ } else {
1772
+ notes.push('page.fetch clone keeps session cookies and auto-applies same-origin auth helpers');
1773
+ }
1774
+
1775
+ const pageFetch =
1776
+ sameOrigin && replayable.bodyTruncated !== true
1777
+ ? {
1778
+ url: replayable.entry.url,
1779
+ method: replayable.entry.method,
1780
+ headers: sanitizedHeaders.headers,
1781
+ body: replayable.body,
1782
+ contentType: replayable.contentType,
1783
+ mode:
1784
+ (replayable.entry.contentType ?? replayable.contentType)?.toLowerCase().includes('json')
1785
+ ? ('json' as const)
1786
+ : ('raw' as const),
1787
+ auth: 'auto' as const
1788
+ }
1789
+ : undefined;
1790
+
1791
+ const preferredArgv =
1792
+ pageFetch
1793
+ ? [
1794
+ 'page',
1795
+ 'fetch',
1796
+ '--url',
1797
+ pageFetch.url,
1798
+ '--method',
1799
+ pageFetch.method,
1800
+ '--auth',
1801
+ pageFetch.auth,
1802
+ '--mode',
1803
+ pageFetch.mode ?? 'raw',
1804
+ ...(pageFetch.contentType ? ['--content-type', pageFetch.contentType] : []),
1805
+ ...Object.entries(pageFetch.headers ?? {}).flatMap(([name, value]) => ['--header', `${name}: ${value}`]),
1806
+ ...(typeof pageFetch.body === 'string' ? ['--body', pageFetch.body] : [])
1807
+ ]
1808
+ : ['network', 'replay', '--request-id', requestId, '--auth', 'auto'];
1809
+
1810
+ return {
1811
+ request: {
1812
+ id: replayable.entry.id,
1813
+ url: replayable.entry.url,
1814
+ method: replayable.entry.method,
1815
+ kind: replayable.entry.kind,
1816
+ contentType: replayable.contentType,
1817
+ sameOrigin,
1818
+ bodyPresent: typeof replayable.body === 'string' && replayable.body.length > 0,
1819
+ bodyTruncated: replayable.bodyTruncated
1820
+ },
1821
+ cloneable,
1822
+ preferredCommand: {
1823
+ tool: pageFetch ? 'page.fetch' : 'network.replay',
1824
+ argv: preferredArgv,
1825
+ powershell: renderPowerShellCommand(preferredArgv)
1826
+ },
1827
+ pageFetch,
1828
+ notes
1829
+ };
1830
+ }
1488
1831
 
1489
1832
  function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
1490
1833
  const regexes = (
@@ -2000,6 +2343,7 @@ async function enrichReplayWithSchema(tabId: number, requestId: string, response
2000
2343
  const pageDataCandidates = await probePageDataCandidatesForTab(tabId, inspection);
2001
2344
  const recentNetwork = listNetworkEntries(tabId, { limit: 25 });
2002
2345
  const pageDataReport = buildInspectPageDataResult({
2346
+ pageUrl: inspection.url,
2003
2347
  suspiciousGlobals: inspection.suspiciousGlobals ?? [],
2004
2348
  tables: inspection.tables ?? [],
2005
2349
  visibleTimestamps: inspection.visibleTimestamps ?? [],
@@ -2007,7 +2351,9 @@ async function enrichReplayWithSchema(tabId: number, requestId: string, response
2007
2351
  pageDataCandidates,
2008
2352
  recentNetwork,
2009
2353
  tableAnalyses: tables,
2010
- inlineJsonSources: Array.isArray(inspection.inlineJsonSources) ? inspection.inlineJsonSources : []
2354
+ inlineJsonSources: Array.isArray(inspection.inlineJsonSources) ? inspection.inlineJsonSources : [],
2355
+ modeGroups: Array.isArray(inspection.modeGroups) ? inspection.modeGroups : [],
2356
+ dateControls: Array.isArray(inspection.dateControls) ? inspection.dateControls : []
2011
2357
  });
2012
2358
  const matched = selectReplaySchemaMatch(response.json, tables, {
2013
2359
  preferredSourceId: `networkResponse:${requestId}`,
@@ -2437,14 +2783,19 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2437
2783
  const tab = await withTab(target);
2438
2784
  try {
2439
2785
  await ensureTabNetworkCapture(tab.id!);
2440
- return {
2441
- entries: listNetworkEntries(tab.id!, {
2442
- limit: typeof params.limit === 'number' ? params.limit : undefined,
2443
- urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
2444
- status: typeof params.status === 'number' ? params.status : undefined,
2445
- method: typeof params.method === 'string' ? params.method : undefined
2446
- })
2447
- };
2786
+ return {
2787
+ entries: listNetworkEntries(tab.id!, {
2788
+ limit: typeof params.limit === 'number' ? params.limit : undefined,
2789
+ urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
2790
+ status: typeof params.status === 'number' ? params.status : undefined,
2791
+ method: typeof params.method === 'string' ? params.method : undefined,
2792
+ domain: typeof params.domain === 'string' ? params.domain : undefined,
2793
+ resourceType: typeof params.resourceType === 'string' ? params.resourceType : undefined,
2794
+ kind: typeof params.kind === 'string' ? (params.kind as NetworkEntry['kind']) : undefined,
2795
+ sinceTs: typeof params.sinceTs === 'number' ? params.sinceTs : undefined,
2796
+ tail: params.tail === true
2797
+ })
2798
+ };
2448
2799
  } catch {
2449
2800
  return await forwardContentRpc(tab.id!, 'network.list', params);
2450
2801
  }
@@ -2479,10 +2830,21 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2479
2830
  return searchNetworkEntries(tab.id!, String(params.pattern ?? ''), typeof params.limit === 'number' ? params.limit : 50);
2480
2831
  });
2481
2832
  }
2482
- case 'network.waitFor': {
2483
- return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2484
- const tab = await withTab(target);
2485
- try {
2833
+ case 'network.clone': {
2834
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2835
+ const tab = await withTab(target);
2836
+ await ensureTabNetworkCapture(tab.id!);
2837
+ const replayable = getReplayableNetworkRequest(tab.id!, String(params.id ?? ''));
2838
+ if (!replayable) {
2839
+ throw toError('E_NOT_FOUND', `network entry not found: ${String(params.id ?? '')}`);
2840
+ }
2841
+ return buildNetworkCloneResult(String(params.id ?? ''), tab.url, replayable);
2842
+ });
2843
+ }
2844
+ case 'network.waitFor': {
2845
+ return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
2846
+ const tab = await withTab(target);
2847
+ try {
2486
2848
  await ensureTabNetworkCapture(tab.id!);
2487
2849
  } catch {
2488
2850
  return await forwardContentRpc(tab.id!, 'network.waitFor', params);
@@ -2631,9 +2993,10 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2631
2993
  await ensureNetworkDebugger(tab.id!).catch(() => undefined);
2632
2994
  const inspection = await collectPageInspection(tab.id!, params);
2633
2995
  const pageDataCandidates = await probePageDataCandidatesForTab(tab.id!, inspection);
2634
- const network = listNetworkEntries(tab.id!, { limit: 10 });
2996
+ const network = listNetworkEntries(tab.id!, { limit: 25, tail: true });
2635
2997
  const tableAnalyses = await collectTableAnalyses(tab.id!);
2636
2998
  const enriched = buildInspectPageDataResult({
2999
+ pageUrl: inspection.url,
2637
3000
  suspiciousGlobals: inspection.suspiciousGlobals ?? [],
2638
3001
  tables: inspection.tables ?? [],
2639
3002
  visibleTimestamps: inspection.visibleTimestamps ?? [],
@@ -2641,7 +3004,9 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2641
3004
  pageDataCandidates,
2642
3005
  recentNetwork: network,
2643
3006
  tableAnalyses,
2644
- inlineJsonSources: Array.isArray(inspection.inlineJsonSources) ? inspection.inlineJsonSources : []
3007
+ inlineJsonSources: Array.isArray(inspection.inlineJsonSources) ? inspection.inlineJsonSources : [],
3008
+ modeGroups: Array.isArray(inspection.modeGroups) ? inspection.modeGroups : [],
3009
+ dateControls: Array.isArray(inspection.dateControls) ? inspection.dateControls : []
2645
3010
  });
2646
3011
  const recommendedNextSteps = enriched.recommendedNextActions.map((action) => action.command);
2647
3012
  return {
@@ -2651,6 +3016,12 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
2651
3016
  inlineTimestamps: inspection.inlineTimestamps ?? [],
2652
3017
  pageDataCandidates,
2653
3018
  recentNetwork: network,
3019
+ modeGroups: Array.isArray(inspection.modeGroups) ? inspection.modeGroups : [],
3020
+ availableModes: enriched.availableModes,
3021
+ currentMode: enriched.currentMode,
3022
+ dateControls: Array.isArray(inspection.dateControls) ? inspection.dateControls : [],
3023
+ latestArchiveDate: enriched.latestArchiveDate,
3024
+ primaryEndpoint: enriched.primaryEndpoint,
2654
3025
  dataSources: enriched.dataSources,
2655
3026
  sourceMappings: enriched.sourceMappings,
2656
3027
  recommendedNextActions: enriched.recommendedNextActions,