@flrande/bak-extension 0.6.0 → 0.6.2

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
@@ -6,7 +6,9 @@ import type {
6
6
  PageExecutionScope,
7
7
  PageFetchResponse,
8
8
  PageFrameResult,
9
- PageFreshnessResult
9
+ PageFreshnessResult,
10
+ TableHandle,
11
+ TableSchema
10
12
  } from '@flrande/bak-protocol';
11
13
  import {
12
14
  clearNetworkEntries,
@@ -22,21 +24,16 @@ import {
22
24
  } from './network-debugger.js';
23
25
  import { isSupportedAutomationUrl } from './url-policy.js';
24
26
  import { computeReconnectDelayMs } from './reconnect.js';
25
- import {
26
- LEGACY_STORAGE_KEY_WORKSPACE,
27
- LEGACY_STORAGE_KEY_WORKSPACES,
28
- resolveSessionBindingStateMap,
29
- STORAGE_KEY_SESSION_BINDINGS
30
- } from './session-binding-storage.js';
27
+ import { resolveSessionBindingStateMap, STORAGE_KEY_SESSION_BINDINGS } from './session-binding-storage.js';
31
28
  import { containsRedactionMarker } from './privacy.js';
32
29
  import {
33
- type WorkspaceBrowser as SessionBindingBrowser,
34
- type WorkspaceColor as SessionBindingColor,
35
- type WorkspaceRecord as SessionBindingRecord,
36
- type WorkspaceTab as SessionBindingTab,
37
- type WorkspaceWindow as SessionBindingWindow,
38
- WorkspaceManager as SessionBindingManager
39
- } from './workspace.js';
30
+ type SessionBindingBrowser,
31
+ type SessionBindingColor,
32
+ type SessionBindingRecord,
33
+ type SessionBindingTab,
34
+ type SessionBindingWindow,
35
+ SessionBindingManager
36
+ } from './session-binding.js';
40
37
 
41
38
  interface CliRequest {
42
39
  id: string;
@@ -74,6 +71,30 @@ const STORAGE_KEY_DEBUG_RICH_TEXT = 'debugRichText';
74
71
  const DEFAULT_TAB_LOAD_TIMEOUT_MS = 40_000;
75
72
  const textEncoder = new TextEncoder();
76
73
  const textDecoder = new TextDecoder();
74
+ const DATA_TIMESTAMP_CONTEXT_PATTERN =
75
+ /\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|freshness|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\b/i;
76
+ const CONTRACT_TIMESTAMP_CONTEXT_PATTERN =
77
+ /\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\b/i;
78
+ const EVENT_TIMESTAMP_CONTEXT_PATTERN = /\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\b/i;
79
+
80
+ interface TimestampEvidenceCandidate {
81
+ value: string;
82
+ source: 'visible' | 'inline' | 'page-data' | 'network';
83
+ context?: string;
84
+ path?: string;
85
+ category?: 'data' | 'contract' | 'event' | 'unknown';
86
+ }
87
+
88
+ interface PageDataCandidateProbe {
89
+ name: string;
90
+ resolver: 'globalThis' | 'lexical';
91
+ sample: unknown;
92
+ timestamps: Array<{
93
+ path: string;
94
+ value: string;
95
+ category: 'data' | 'contract' | 'event' | 'unknown';
96
+ }>;
97
+ }
77
98
  const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
78
99
  'accept-encoding',
79
100
  'authorization',
@@ -87,12 +108,13 @@ const REPLAY_FORBIDDEN_HEADER_NAMES = new Set([
87
108
  'set-cookie'
88
109
  ]);
89
110
 
90
- let ws: WebSocket | null = null;
91
- let reconnectTimer: number | null = null;
92
- let nextReconnectInMs: number | null = null;
93
- let reconnectAttempt = 0;
94
- let lastError: RuntimeErrorDetails | null = null;
95
- let manualDisconnect = false;
111
+ let ws: WebSocket | null = null;
112
+ let reconnectTimer: number | null = null;
113
+ let nextReconnectInMs: number | null = null;
114
+ let reconnectAttempt = 0;
115
+ let lastError: RuntimeErrorDetails | null = null;
116
+ let manualDisconnect = false;
117
+ let sessionBindingStateMutationQueue: Promise<void> = Promise.resolve();
96
118
 
97
119
  async function getConfig(): Promise<ExtensionConfig> {
98
120
  const stored = await chrome.storage.local.get([STORAGE_KEY_TOKEN, STORAGE_KEY_PORT, STORAGE_KEY_DEBUG_RICH_TEXT]);
@@ -156,12 +178,12 @@ function normalizeUnhandledError(error: unknown): CliResponse['error'] {
156
178
  if (lower.includes('no tab with id') || lower.includes('no window with id')) {
157
179
  return toError('E_NOT_FOUND', message);
158
180
  }
159
- if (lower.includes('workspace') && lower.includes('does not exist')) {
160
- return toError('E_NOT_FOUND', message);
161
- }
162
- if (lower.includes('does not belong to workspace') || lower.includes('is missing from workspace')) {
163
- return toError('E_NOT_FOUND', message);
164
- }
181
+ if (lower.includes('binding') && lower.includes('does not exist')) {
182
+ return toError('E_NOT_FOUND', message);
183
+ }
184
+ if (lower.includes('does not belong to binding') || lower.includes('is missing from binding')) {
185
+ return toError('E_NOT_FOUND', message);
186
+ }
165
187
  if (lower.includes('invalid url') || lower.includes('url is invalid')) {
166
188
  return toError('E_INVALID_PARAMS', message);
167
189
  }
@@ -186,43 +208,64 @@ function toTabInfo(tab: chrome.tabs.Tab): SessionBindingTab {
186
208
  };
187
209
  }
188
210
 
189
- async function loadWorkspaceStateMap(): Promise<Record<string, SessionBindingRecord>> {
190
- const stored = await chrome.storage.local.get([
191
- STORAGE_KEY_SESSION_BINDINGS,
192
- LEGACY_STORAGE_KEY_WORKSPACES,
193
- LEGACY_STORAGE_KEY_WORKSPACE
194
- ]);
195
- return resolveSessionBindingStateMap(stored);
196
- }
197
-
198
- async function loadWorkspaceState(workspaceId: string): Promise<SessionBindingRecord | null> {
199
- const stateMap = await loadWorkspaceStateMap();
200
- return stateMap[workspaceId] ?? null;
201
- }
211
+ async function readSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
212
+ const stored = await chrome.storage.local.get([STORAGE_KEY_SESSION_BINDINGS]);
213
+ return resolveSessionBindingStateMap(stored);
214
+ }
215
+
216
+ async function flushSessionBindingStateMap(stateMap: Record<string, SessionBindingRecord>): Promise<void> {
217
+ if (Object.keys(stateMap).length === 0) {
218
+ await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS]);
219
+ return;
220
+ }
221
+ await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
222
+ }
223
+
224
+ async function runSessionBindingStateMutation<T>(operation: () => Promise<T>): Promise<T> {
225
+ const run = sessionBindingStateMutationQueue.then(operation, operation);
226
+ sessionBindingStateMutationQueue = run.then(
227
+ () => undefined,
228
+ () => undefined
229
+ );
230
+ return run;
231
+ }
232
+
233
+ async function mutateSessionBindingStateMap<T>(mutator: (stateMap: Record<string, SessionBindingRecord>) => Promise<T> | T): Promise<T> {
234
+ return await runSessionBindingStateMutation(async () => {
235
+ const stateMap = await readSessionBindingStateMap();
236
+ const result = await mutator(stateMap);
237
+ await flushSessionBindingStateMap(stateMap);
238
+ return result;
239
+ });
240
+ }
241
+
242
+ async function loadSessionBindingStateMap(): Promise<Record<string, SessionBindingRecord>> {
243
+ await sessionBindingStateMutationQueue;
244
+ return await readSessionBindingStateMap();
245
+ }
202
246
 
203
- async function listWorkspaceStates(): Promise<SessionBindingRecord[]> {
204
- return Object.values(await loadWorkspaceStateMap());
247
+ async function loadSessionBindingState(bindingId: string): Promise<SessionBindingRecord | null> {
248
+ const stateMap = await loadSessionBindingStateMap();
249
+ return stateMap[bindingId] ?? null;
205
250
  }
206
251
 
207
- async function saveWorkspaceState(state: SessionBindingRecord): Promise<void> {
208
- const stateMap = await loadWorkspaceStateMap();
209
- stateMap[state.id] = state;
210
- await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
211
- await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
252
+ async function listSessionBindingStates(): Promise<SessionBindingRecord[]> {
253
+ return Object.values(await loadSessionBindingStateMap());
212
254
  }
213
255
 
214
- async function deleteWorkspaceState(workspaceId: string): Promise<void> {
215
- const stateMap = await loadWorkspaceStateMap();
216
- delete stateMap[workspaceId];
217
- if (Object.keys(stateMap).length === 0) {
218
- await chrome.storage.local.remove([STORAGE_KEY_SESSION_BINDINGS, LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
219
- return;
220
- }
221
- await chrome.storage.local.set({ [STORAGE_KEY_SESSION_BINDINGS]: stateMap });
222
- await chrome.storage.local.remove([LEGACY_STORAGE_KEY_WORKSPACES, LEGACY_STORAGE_KEY_WORKSPACE]);
223
- }
256
+ async function saveSessionBindingState(state: SessionBindingRecord): Promise<void> {
257
+ await mutateSessionBindingStateMap((stateMap) => {
258
+ stateMap[state.id] = state;
259
+ });
260
+ }
261
+
262
+ async function deleteSessionBindingState(bindingId: string): Promise<void> {
263
+ await mutateSessionBindingStateMap((stateMap) => {
264
+ delete stateMap[bindingId];
265
+ });
266
+ }
224
267
 
225
- const workspaceBrowser: SessionBindingBrowser = {
268
+ const sessionBindingBrowser: SessionBindingBrowser = {
226
269
  async getTab(tabId) {
227
270
  try {
228
271
  return toTabInfo(await chrome.tabs.get(tabId));
@@ -361,14 +404,14 @@ const workspaceBrowser: SessionBindingBrowser = {
361
404
  }
362
405
  };
363
406
 
364
- const bindingManager = new SessionBindingManager(
365
- {
366
- load: loadWorkspaceState,
367
- save: saveWorkspaceState,
368
- delete: deleteWorkspaceState,
369
- list: listWorkspaceStates
370
- },
371
- workspaceBrowser
407
+ const bindingManager = new SessionBindingManager(
408
+ {
409
+ load: loadSessionBindingState,
410
+ save: saveSessionBindingState,
411
+ delete: deleteSessionBindingState,
412
+ list: listSessionBindingStates
413
+ },
414
+ sessionBindingBrowser
372
415
  );
373
416
 
374
417
  async function waitForTabComplete(tabId: number, timeoutMs = DEFAULT_TAB_LOAD_TIMEOUT_MS): Promise<void> {
@@ -475,10 +518,10 @@ function normalizeComparableTabUrl(url: string): string {
475
518
  }
476
519
  }
477
520
 
478
- async function finalizeOpenedWorkspaceTab(
479
- opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
480
- expectedUrl?: string
481
- ): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
521
+ async function finalizeOpenedSessionBindingTab(
522
+ opened: Awaited<ReturnType<SessionBindingManager['openTab']>>,
523
+ expectedUrl?: string
524
+ ): Promise<Awaited<ReturnType<SessionBindingManager['openTab']>>> {
482
525
  if (expectedUrl && expectedUrl !== 'about:blank') {
483
526
  await waitForTabUrl(opened.tab.id, expectedUrl).catch(() => undefined);
484
527
  }
@@ -498,24 +541,24 @@ async function finalizeOpenedWorkspaceTab(
498
541
  url: effectiveUrl
499
542
  };
500
543
  } catch {
501
- refreshedTab = (await workspaceBrowser.getTab(opened.tab.id)) ?? opened.tab;
502
- }
503
- const refreshedWorkspace = (await bindingManager.getWorkspaceInfo(opened.workspace.id)) ?? {
504
- ...opened.workspace,
505
- tabs: opened.workspace.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
544
+ refreshedTab = (await sessionBindingBrowser.getTab(opened.tab.id)) ?? opened.tab;
545
+ }
546
+ const refreshedBinding = (await bindingManager.getBindingInfo(opened.binding.id)) ?? {
547
+ ...opened.binding,
548
+ tabs: opened.binding.tabs.map((tab) => (tab.id === refreshedTab.id ? refreshedTab : tab))
506
549
  };
507
550
 
508
551
  return {
509
- workspace: refreshedWorkspace,
552
+ binding: refreshedBinding,
510
553
  tab: refreshedTab
511
554
  };
512
555
  }
513
556
 
514
- interface WithTabOptions {
515
- requireSupportedAutomationUrl?: boolean;
516
- }
517
-
518
- async function withTab(target: { tabId?: number; workspaceId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
557
+ interface WithTabOptions {
558
+ requireSupportedAutomationUrl?: boolean;
559
+ }
560
+
561
+ async function withTab(target: { tabId?: number; bindingId?: string } = {}, options: WithTabOptions = {}): Promise<chrome.tabs.Tab> {
519
562
  const requireSupportedAutomationUrl = options.requireSupportedAutomationUrl !== false;
520
563
  const validate = (tab: chrome.tabs.Tab): chrome.tabs.Tab => {
521
564
  if (!tab.id) {
@@ -535,11 +578,11 @@ async function withTab(target: { tabId?: number; workspaceId?: string } = {}, op
535
578
  return validate(tab);
536
579
  }
537
580
 
538
- const resolved = await bindingManager.resolveTarget({
539
- tabId: target.tabId,
540
- workspaceId: typeof target.workspaceId === 'string' ? target.workspaceId : undefined,
541
- createIfMissing: false
542
- });
581
+ const resolved = await bindingManager.resolveTarget({
582
+ tabId: target.tabId,
583
+ bindingId: typeof target.bindingId === 'string' ? target.bindingId : undefined,
584
+ createIfMissing: false
585
+ });
543
586
  const tab = await chrome.tabs.get(resolved.tab.id);
544
587
  return validate(tab);
545
588
  }
@@ -719,6 +762,7 @@ async function executePageWorld<T>(
719
762
  framePath,
720
763
  expr: typeof params.expr === 'string' ? params.expr : '',
721
764
  path: typeof params.path === 'string' ? params.path : '',
765
+ resolver: typeof params.resolver === 'string' ? params.resolver : undefined,
722
766
  url: typeof params.url === 'string' ? params.url : '',
723
767
  method: typeof params.method === 'string' ? params.method : 'GET',
724
768
  headers: typeof params.headers === 'object' && params.headers !== null ? params.headers : undefined,
@@ -804,6 +848,64 @@ async function executePageWorld<T>(
804
848
  return currentWindow;
805
849
  };
806
850
 
851
+ const buildPathExpression = (path: string): string =>
852
+ parsePath(path)
853
+ .map((segment, index) => {
854
+ if (typeof segment === 'number') {
855
+ return `[${segment}]`;
856
+ }
857
+ if (index === 0) {
858
+ return segment;
859
+ }
860
+ return `.${segment}`;
861
+ })
862
+ .join('');
863
+
864
+ const readPath = (targetWindow: Window, path: string): unknown => {
865
+ const segments = parsePath(path);
866
+ let current: unknown = targetWindow;
867
+ for (const segment of segments) {
868
+ if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
869
+ throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
870
+ }
871
+ current = (current as Record<string | number, unknown>)[segment];
872
+ }
873
+ return current;
874
+ };
875
+
876
+ const resolveExtractValue = (
877
+ targetWindow: Window & { eval: (expr: string) => unknown },
878
+ path: string,
879
+ resolver: unknown
880
+ ): { resolver: 'globalThis' | 'lexical'; value: unknown } => {
881
+ const strategy = resolver === 'globalThis' || resolver === 'lexical' ? resolver : 'auto';
882
+ const lexicalExpression = buildPathExpression(path);
883
+ const readLexical = (): unknown => {
884
+ try {
885
+ return targetWindow.eval(lexicalExpression);
886
+ } catch (error) {
887
+ if (error instanceof ReferenceError) {
888
+ throw { code: 'E_NOT_FOUND', message: `path not found: ${path}` };
889
+ }
890
+ throw error;
891
+ }
892
+ };
893
+ if (strategy === 'globalThis') {
894
+ return { resolver: 'globalThis', value: readPath(targetWindow, path) };
895
+ }
896
+ if (strategy === 'lexical') {
897
+ return { resolver: 'lexical', value: readLexical() };
898
+ }
899
+ try {
900
+ return { resolver: 'globalThis', value: readPath(targetWindow, path) };
901
+ } catch (error) {
902
+ if (typeof error !== 'object' || error === null || (error as { code?: string }).code !== 'E_NOT_FOUND') {
903
+ throw error;
904
+ }
905
+ }
906
+ return { resolver: 'lexical', value: readLexical() };
907
+ };
908
+
807
909
  try {
808
910
  const targetWindow = payload.scope === 'main' ? window : payload.scope === 'current' ? resolveFrameWindow(payload.framePath ?? []) : window;
809
911
  if (payload.action === 'eval') {
@@ -812,16 +914,15 @@ async function executePageWorld<T>(
812
914
  return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
813
915
  }
814
916
  if (payload.action === 'extract') {
815
- const segments = parsePath(payload.path);
816
- let current: unknown = targetWindow;
817
- for (const segment of segments) {
818
- if (current === null || current === undefined || !(segment in (current as Record<string | number, unknown>))) {
819
- throw { code: 'E_NOT_FOUND', message: `path not found: ${payload.path}` };
820
- }
821
- current = (current as Record<string | number, unknown>)[segment];
822
- }
823
- const serialized = serializeValue(current, payload.maxBytes);
824
- return { url: targetWindow.location.href, framePath: payload.scope === 'current' ? payload.framePath ?? [] : [], value: serialized.value, bytes: serialized.bytes };
917
+ const extracted = resolveExtractValue(targetWindow as Window & { eval: (expr: string) => unknown }, payload.path, payload.resolver);
918
+ const serialized = serializeValue(extracted.value, payload.maxBytes);
919
+ return {
920
+ url: targetWindow.location.href,
921
+ framePath: payload.scope === 'current' ? payload.framePath ?? [] : [],
922
+ value: serialized.value,
923
+ bytes: serialized.bytes,
924
+ resolver: extracted.resolver
925
+ };
825
926
  }
826
927
  if (payload.action === 'fetch') {
827
928
  const headers = { ...(payload.headers ?? {}) } as Record<string, string>;
@@ -988,6 +1089,34 @@ function replayHeadersFromEntry(entry: NetworkEntry): Record<string, string> | u
988
1089
  return Object.keys(headers).length > 0 ? headers : undefined;
989
1090
  }
990
1091
 
1092
+ function collectTimestampMatchesFromText(text: string, source: TimestampEvidenceCandidate['source'], patterns?: string[]): TimestampEvidenceCandidate[] {
1093
+ const regexes = (
1094
+ patterns ?? [
1095
+ String.raw`\b20\d{2}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`,
1096
+ String.raw`\b20\d{2}-\d{2}-\d{2}\b`,
1097
+ String.raw`\b20\d{2}\/\d{2}\/\d{2}\b`
1098
+ ]
1099
+ ).map((pattern) => new RegExp(pattern, 'gi'));
1100
+ const collected = new Map<string, TimestampEvidenceCandidate>();
1101
+ for (const regex of regexes) {
1102
+ for (const match of text.matchAll(regex)) {
1103
+ const value = match[0];
1104
+ if (!value) {
1105
+ continue;
1106
+ }
1107
+ const index = match.index ?? text.indexOf(value);
1108
+ const start = Math.max(0, index - 28);
1109
+ const end = Math.min(text.length, index + value.length + 28);
1110
+ const context = text.slice(start, end).replace(/\s+/g, ' ').trim();
1111
+ const key = `${value}::${context}`;
1112
+ if (!collected.has(key)) {
1113
+ collected.set(key, { value, source, context });
1114
+ }
1115
+ }
1116
+ }
1117
+ return [...collected.values()];
1118
+ }
1119
+
991
1120
  function parseTimestampCandidate(value: string, now = Date.now()): number | null {
992
1121
  const normalized = value.trim().toLowerCase();
993
1122
  if (!normalized) {
@@ -1003,13 +1132,71 @@ function parseTimestampCandidate(value: string, now = Date.now()): number | null
1003
1132
  return Number.isNaN(parsed) ? null : parsed;
1004
1133
  }
1005
1134
 
1006
- function extractLatestTimestamp(values: string[] | undefined, now = Date.now()): number | null {
1007
- if (!Array.isArray(values) || values.length === 0) {
1135
+ function nearestPatternDistance(text: string, anchor: string, pattern: RegExp): number | null {
1136
+ const normalizedText = text.toLowerCase();
1137
+ const normalizedAnchor = anchor.toLowerCase();
1138
+ const anchorIndex = normalizedText.indexOf(normalizedAnchor);
1139
+ if (anchorIndex < 0) {
1008
1140
  return null;
1009
1141
  }
1142
+ const regex = new RegExp(pattern.source, 'gi');
1143
+ let match: RegExpExecArray | null;
1144
+ let best: number | null = null;
1145
+ while ((match = regex.exec(normalizedText)) !== null) {
1146
+ best = best === null ? Math.abs(anchorIndex - match.index) : Math.min(best, Math.abs(anchorIndex - match.index));
1147
+ }
1148
+ return best;
1149
+ }
1150
+
1151
+ function classifyTimestampCandidate(candidate: TimestampEvidenceCandidate, now = Date.now()): PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] {
1152
+ const normalizedPath = (candidate.path ?? '').toLowerCase();
1153
+ if (DATA_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
1154
+ return 'data';
1155
+ }
1156
+ if (CONTRACT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
1157
+ return 'contract';
1158
+ }
1159
+ if (EVENT_TIMESTAMP_CONTEXT_PATTERN.test(normalizedPath)) {
1160
+ return 'event';
1161
+ }
1162
+
1163
+ const context = candidate.context ?? '';
1164
+ const distances = [
1165
+ { category: 'data' as const, distance: nearestPatternDistance(context, candidate.value, DATA_TIMESTAMP_CONTEXT_PATTERN) },
1166
+ { category: 'contract' as const, distance: nearestPatternDistance(context, candidate.value, CONTRACT_TIMESTAMP_CONTEXT_PATTERN) },
1167
+ { category: 'event' as const, distance: nearestPatternDistance(context, candidate.value, EVENT_TIMESTAMP_CONTEXT_PATTERN) }
1168
+ ].filter((entry): entry is { category: 'data' | 'contract' | 'event'; distance: number } => typeof entry.distance === 'number');
1169
+ if (distances.length > 0) {
1170
+ distances.sort((left, right) => left.distance - right.distance);
1171
+ return distances[0]!.category;
1172
+ }
1173
+ const parsed = parseTimestampCandidate(candidate.value, now);
1174
+ return typeof parsed === 'number' && parsed > now + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
1175
+ }
1176
+
1177
+ function normalizeTimestampCandidates(
1178
+ candidates: TimestampEvidenceCandidate[],
1179
+ now = Date.now()
1180
+ ): PageFreshnessResult['evidence']['classifiedTimestamps'] {
1181
+ return candidates.map((candidate) => ({
1182
+ value: candidate.value,
1183
+ source: candidate.source,
1184
+ category: candidate.category ?? classifyTimestampCandidate(candidate, now),
1185
+ context: candidate.context,
1186
+ path: candidate.path
1187
+ }));
1188
+ }
1189
+
1190
+ function latestTimestampFromCandidates(
1191
+ candidates: Array<{ value: string; category: PageFreshnessResult['evidence']['classifiedTimestamps'][number]['category'] }>,
1192
+ now = Date.now()
1193
+ ): number | null {
1010
1194
  let latest: number | null = null;
1011
- for (const value of values) {
1012
- const parsed = parseTimestampCandidate(value, now);
1195
+ for (const candidate of candidates) {
1196
+ if (candidate.category === 'contract' || candidate.category === 'event') {
1197
+ continue;
1198
+ }
1199
+ const parsed = parseTimestampCandidate(candidate.value, now);
1013
1200
  if (parsed === null) {
1014
1201
  continue;
1015
1202
  }
@@ -1020,6 +1207,8 @@ function extractLatestTimestamp(values: string[] | undefined, now = Date.now()):
1020
1207
 
1021
1208
  function computeFreshnessAssessment(input: {
1022
1209
  latestInlineDataTimestamp: number | null;
1210
+ latestPageDataTimestamp: number | null;
1211
+ latestNetworkDataTimestamp: number | null;
1023
1212
  latestNetworkTimestamp: number | null;
1024
1213
  domVisibleTimestamp: number | null;
1025
1214
  lastMutationAt: number | null;
@@ -1027,19 +1216,29 @@ function computeFreshnessAssessment(input: {
1027
1216
  staleWindowMs: number;
1028
1217
  }): PageFreshnessResult['assessment'] {
1029
1218
  const now = Date.now();
1030
- const latestDataTimestamp = [input.latestInlineDataTimestamp, input.domVisibleTimestamp]
1219
+ const latestPageVisibleTimestamp = [input.latestPageDataTimestamp, input.latestInlineDataTimestamp, input.domVisibleTimestamp]
1031
1220
  .filter((value): value is number => typeof value === 'number')
1032
1221
  .sort((left, right) => right - left)[0] ?? null;
1033
- if (latestDataTimestamp !== null && now - latestDataTimestamp <= input.freshWindowMs) {
1222
+ if (latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp <= input.freshWindowMs) {
1034
1223
  return 'fresh';
1035
1224
  }
1225
+ const networkHasFreshData =
1226
+ typeof input.latestNetworkDataTimestamp === 'number' && now - input.latestNetworkDataTimestamp <= input.freshWindowMs;
1227
+ if (networkHasFreshData) {
1228
+ return 'lagged';
1229
+ }
1036
1230
  const recentSignals = [input.latestNetworkTimestamp, input.lastMutationAt]
1037
1231
  .filter((value): value is number => typeof value === 'number')
1038
1232
  .some((value) => now - value <= input.freshWindowMs);
1039
- if (recentSignals && latestDataTimestamp !== null && now - latestDataTimestamp > input.freshWindowMs) {
1233
+ if (recentSignals && latestPageVisibleTimestamp !== null && now - latestPageVisibleTimestamp > input.freshWindowMs) {
1040
1234
  return 'lagged';
1041
1235
  }
1042
- const staleSignals = [input.latestNetworkTimestamp, input.lastMutationAt, latestDataTimestamp]
1236
+ const staleSignals = [
1237
+ input.latestNetworkTimestamp,
1238
+ input.lastMutationAt,
1239
+ latestPageVisibleTimestamp,
1240
+ input.latestNetworkDataTimestamp
1241
+ ]
1043
1242
  .filter((value): value is number => typeof value === 'number');
1044
1243
  if (staleSignals.length > 0 && staleSignals.every((value) => now - value > input.staleWindowMs)) {
1045
1244
  return 'stale';
@@ -1051,25 +1250,192 @@ async function collectPageInspection(tabId: number, params: Record<string, unkno
1051
1250
  return (await forwardContentRpc(tabId, 'bak.internal.inspectState', params)) as Record<string, unknown>;
1052
1251
  }
1053
1252
 
1253
+ async function probePageDataCandidatesForTab(tabId: number, inspection: Record<string, unknown>): Promise<PageDataCandidateProbe[]> {
1254
+ const candidateNames = [
1255
+ ...(Array.isArray(inspection.suspiciousGlobals) ? inspection.suspiciousGlobals.map(String) : []),
1256
+ ...(Array.isArray(inspection.globalsPreview) ? inspection.globalsPreview.map(String) : [])
1257
+ ]
1258
+ .filter((name, index, array) => /^[A-Za-z_$][\w$]*$/.test(name) && array.indexOf(name) === index)
1259
+ .slice(0, 16);
1260
+ if (candidateNames.length === 0) {
1261
+ return [];
1262
+ }
1263
+
1264
+ const expr = `(() => {
1265
+ const candidates = ${JSON.stringify(candidateNames)};
1266
+ const dataPattern = /\\b(updated|update|updatedat|asof|timestamp|generated|generatedat|refresh|latest|last|quote|trade|price|flow|market|time|snapshot|signal)\\b/i;
1267
+ const contractPattern = /\\b(expiry|expiration|expires|option|contract|strike|maturity|dte|call|put|exercise)\\b/i;
1268
+ const eventPattern = /\\b(earnings|event|report|dividend|split|meeting|fomc|release|filing)\\b/i;
1269
+ const isTimestampString = (value) => typeof value === 'string' && value.trim().length > 0 && !Number.isNaN(Date.parse(value.trim()));
1270
+ const classify = (path, value) => {
1271
+ const normalized = String(path || '').toLowerCase();
1272
+ if (dataPattern.test(normalized)) return 'data';
1273
+ if (contractPattern.test(normalized)) return 'contract';
1274
+ if (eventPattern.test(normalized)) return 'event';
1275
+ const parsed = Date.parse(String(value || '').trim());
1276
+ return Number.isFinite(parsed) && parsed > Date.now() + 36 * 60 * 60 * 1000 ? 'contract' : 'unknown';
1277
+ };
1278
+ const sampleValue = (value, depth = 0) => {
1279
+ if (depth >= 2 || value == null || typeof value !== 'object') {
1280
+ if (typeof value === 'string') {
1281
+ return value.length > 160 ? value.slice(0, 160) : value;
1282
+ }
1283
+ if (typeof value === 'function') {
1284
+ return '[Function]';
1285
+ }
1286
+ return value;
1287
+ }
1288
+ if (Array.isArray(value)) {
1289
+ return value.slice(0, 3).map((item) => sampleValue(item, depth + 1));
1290
+ }
1291
+ const sampled = {};
1292
+ for (const key of Object.keys(value).slice(0, 8)) {
1293
+ try {
1294
+ sampled[key] = sampleValue(value[key], depth + 1);
1295
+ } catch {
1296
+ sampled[key] = '[Unreadable]';
1297
+ }
1298
+ }
1299
+ return sampled;
1300
+ };
1301
+ const collectTimestamps = (value, path, depth, collected) => {
1302
+ if (collected.length >= 16) return;
1303
+ if (isTimestampString(value)) {
1304
+ collected.push({ path, value: String(value), category: classify(path, value) });
1305
+ return;
1306
+ }
1307
+ if (depth >= 3) return;
1308
+ if (Array.isArray(value)) {
1309
+ value.slice(0, 3).forEach((item, index) => collectTimestamps(item, path + '[' + index + ']', depth + 1, collected));
1310
+ return;
1311
+ }
1312
+ if (value && typeof value === 'object') {
1313
+ Object.keys(value)
1314
+ .slice(0, 8)
1315
+ .forEach((key) => {
1316
+ try {
1317
+ collectTimestamps(value[key], path ? path + '.' + key : key, depth + 1, collected);
1318
+ } catch {
1319
+ // Ignore unreadable nested properties.
1320
+ }
1321
+ });
1322
+ }
1323
+ };
1324
+ const readCandidate = (name) => {
1325
+ if (name in globalThis) {
1326
+ return { resolver: 'globalThis', value: globalThis[name] };
1327
+ }
1328
+ return { resolver: 'lexical', value: globalThis.eval(name) };
1329
+ };
1330
+ const results = [];
1331
+ for (const name of candidates) {
1332
+ try {
1333
+ const resolved = readCandidate(name);
1334
+ const timestamps = [];
1335
+ collectTimestamps(resolved.value, name, 0, timestamps);
1336
+ results.push({
1337
+ name,
1338
+ resolver: resolved.resolver,
1339
+ sample: sampleValue(resolved.value),
1340
+ timestamps
1341
+ });
1342
+ } catch {
1343
+ // Ignore inaccessible candidates.
1344
+ }
1345
+ }
1346
+ return results;
1347
+ })()`;
1348
+
1349
+ try {
1350
+ const evaluated = await executePageWorld<PageDataCandidateProbe[]>(tabId, 'eval', {
1351
+ expr,
1352
+ scope: 'current',
1353
+ maxBytes: 64 * 1024
1354
+ });
1355
+ const frameResult = evaluated.result ?? evaluated.results?.find((candidate) => candidate.value || candidate.error);
1356
+ return Array.isArray(frameResult?.value) ? frameResult.value : [];
1357
+ } catch {
1358
+ return [];
1359
+ }
1360
+ }
1361
+
1054
1362
  async function buildFreshnessForTab(tabId: number, params: Record<string, unknown> = {}): Promise<PageFreshnessResult> {
1055
1363
  const inspection = await collectPageInspection(tabId, params);
1056
- const visibleTimestamps = Array.isArray(inspection.visibleTimestamps) ? inspection.visibleTimestamps.map(String) : [];
1057
- const inlineTimestamps = Array.isArray(inspection.inlineTimestamps) ? inspection.inlineTimestamps.map(String) : [];
1364
+ const probedPageDataCandidates = await probePageDataCandidatesForTab(tabId, inspection);
1058
1365
  const now = Date.now();
1059
1366
  const freshWindowMs = typeof params.freshWindowMs === 'number' ? Math.max(1, Math.floor(params.freshWindowMs)) : 15 * 60 * 1000;
1060
1367
  const staleWindowMs = typeof params.staleWindowMs === 'number' ? Math.max(freshWindowMs, Math.floor(params.staleWindowMs)) : 24 * 60 * 60 * 1000;
1061
- const latestInlineDataTimestamp = extractLatestTimestamp(inlineTimestamps, now);
1062
- const domVisibleTimestamp = extractLatestTimestamp(visibleTimestamps, now);
1368
+ const visibleCandidates = normalizeTimestampCandidates(
1369
+ Array.isArray(inspection.visibleTimestampCandidates)
1370
+ ? inspection.visibleTimestampCandidates
1371
+ .filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
1372
+ .map((candidate) => ({
1373
+ value: String(candidate.value ?? ''),
1374
+ context: typeof candidate.context === 'string' ? candidate.context : undefined,
1375
+ source: 'visible' as const
1376
+ }))
1377
+ : Array.isArray(inspection.visibleTimestamps)
1378
+ ? inspection.visibleTimestamps.map((value) => ({ value: String(value), source: 'visible' as const }))
1379
+ : [],
1380
+ now
1381
+ );
1382
+ const inlineCandidates = normalizeTimestampCandidates(
1383
+ Array.isArray(inspection.inlineTimestampCandidates)
1384
+ ? inspection.inlineTimestampCandidates
1385
+ .filter((candidate): candidate is Record<string, unknown> => typeof candidate === 'object' && candidate !== null)
1386
+ .map((candidate) => ({
1387
+ value: String(candidate.value ?? ''),
1388
+ context: typeof candidate.context === 'string' ? candidate.context : undefined,
1389
+ source: 'inline' as const
1390
+ }))
1391
+ : Array.isArray(inspection.inlineTimestamps)
1392
+ ? inspection.inlineTimestamps.map((value) => ({ value: String(value), source: 'inline' as const }))
1393
+ : [],
1394
+ now
1395
+ );
1396
+ const pageDataCandidates = probedPageDataCandidates.flatMap((candidate) =>
1397
+ Array.isArray(candidate.timestamps)
1398
+ ? candidate.timestamps.map((timestamp) => ({
1399
+ value: String(timestamp.value ?? ''),
1400
+ source: 'page-data' as const,
1401
+ path: typeof timestamp.path === 'string' ? timestamp.path : candidate.name,
1402
+ category:
1403
+ timestamp.category === 'data' ||
1404
+ timestamp.category === 'contract' ||
1405
+ timestamp.category === 'event' ||
1406
+ timestamp.category === 'unknown'
1407
+ ? timestamp.category
1408
+ : 'unknown'
1409
+ }))
1410
+ : []
1411
+ );
1412
+ const networkEntries = listNetworkEntries(tabId, { limit: 25 });
1413
+ const networkCandidates = normalizeTimestampCandidates(
1414
+ networkEntries.flatMap((entry) => {
1415
+ const previews = [entry.responseBodyPreview, entry.requestBodyPreview].filter((value): value is string => typeof value === 'string');
1416
+ return previews.flatMap((preview) => collectTimestampMatchesFromText(preview, 'network', Array.isArray(params.patterns) ? params.patterns.map(String) : undefined));
1417
+ }),
1418
+ now
1419
+ );
1420
+ const latestInlineDataTimestamp = latestTimestampFromCandidates(inlineCandidates, now);
1421
+ const latestPageDataTimestamp = latestTimestampFromCandidates(pageDataCandidates, now);
1422
+ const latestNetworkDataTimestamp = latestTimestampFromCandidates(networkCandidates, now);
1423
+ const domVisibleTimestamp = latestTimestampFromCandidates(visibleCandidates, now);
1063
1424
  const latestNetworkTs = latestNetworkTimestamp(tabId);
1064
1425
  const lastMutationAt = typeof inspection.lastMutationAt === 'number' ? inspection.lastMutationAt : null;
1426
+ const allCandidates = [...visibleCandidates, ...inlineCandidates, ...pageDataCandidates, ...networkCandidates];
1065
1427
  return {
1066
1428
  pageLoadedAt: typeof inspection.pageLoadedAt === 'number' ? inspection.pageLoadedAt : null,
1067
1429
  lastMutationAt,
1068
1430
  latestNetworkTimestamp: latestNetworkTs,
1069
1431
  latestInlineDataTimestamp,
1432
+ latestPageDataTimestamp,
1433
+ latestNetworkDataTimestamp,
1070
1434
  domVisibleTimestamp,
1071
1435
  assessment: computeFreshnessAssessment({
1072
1436
  latestInlineDataTimestamp,
1437
+ latestPageDataTimestamp,
1438
+ latestNetworkDataTimestamp,
1073
1439
  latestNetworkTimestamp: latestNetworkTs,
1074
1440
  domVisibleTimestamp,
1075
1441
  lastMutationAt,
@@ -1077,19 +1443,142 @@ async function buildFreshnessForTab(tabId: number, params: Record<string, unknow
1077
1443
  staleWindowMs
1078
1444
  }),
1079
1445
  evidence: {
1080
- visibleTimestamps,
1081
- inlineTimestamps,
1446
+ visibleTimestamps: visibleCandidates.map((candidate) => candidate.value),
1447
+ inlineTimestamps: inlineCandidates.map((candidate) => candidate.value),
1448
+ pageDataTimestamps: pageDataCandidates.map((candidate) => candidate.value),
1449
+ networkDataTimestamps: networkCandidates.map((candidate) => candidate.value),
1450
+ classifiedTimestamps: allCandidates,
1082
1451
  networkSampleIds: recentNetworkSampleIds(tabId)
1083
1452
  }
1084
1453
  };
1085
1454
  }
1455
+
1456
+ function summarizeNetworkCadence(entries: NetworkEntry[]): Record<string, unknown> {
1457
+ const relevant = entries
1458
+ .filter((entry) => entry.kind === 'fetch' || entry.kind === 'xhr')
1459
+ .slice()
1460
+ .sort((left, right) => left.ts - right.ts);
1461
+ if (relevant.length === 0) {
1462
+ return {
1463
+ sampleCount: 0,
1464
+ classification: 'none',
1465
+ averageIntervalMs: null,
1466
+ medianIntervalMs: null,
1467
+ latestGapMs: null,
1468
+ endpoints: []
1469
+ };
1470
+ }
1471
+ const intervals: number[] = [];
1472
+ for (let index = 1; index < relevant.length; index += 1) {
1473
+ intervals.push(Math.max(0, relevant[index]!.ts - relevant[index - 1]!.ts));
1474
+ }
1475
+ const sortedIntervals = intervals.slice().sort((left, right) => left - right);
1476
+ const averageIntervalMs =
1477
+ intervals.length > 0 ? Math.round(intervals.reduce((sum, value) => sum + value, 0) / intervals.length) : null;
1478
+ const medianIntervalMs =
1479
+ sortedIntervals.length > 0 ? sortedIntervals[Math.floor(sortedIntervals.length / 2)] ?? null : null;
1480
+ const latestGapMs = Math.max(0, Date.now() - relevant[relevant.length - 1]!.ts);
1481
+ const classification =
1482
+ relevant.length >= 3 && medianIntervalMs !== null && medianIntervalMs <= 30_000
1483
+ ? 'polling'
1484
+ : relevant.length >= 2
1485
+ ? 'bursty'
1486
+ : 'single-request';
1487
+ return {
1488
+ sampleCount: relevant.length,
1489
+ classification,
1490
+ averageIntervalMs,
1491
+ medianIntervalMs,
1492
+ latestGapMs,
1493
+ endpoints: [...new Set(relevant.slice(-5).map((entry) => entry.url))].slice(0, 5)
1494
+ };
1495
+ }
1496
+
1497
+ function extractReplayRowsCandidate(json: unknown): { rows: unknown[]; source: string } | null {
1498
+ if (Array.isArray(json)) {
1499
+ return { rows: json, source: '$' };
1500
+ }
1501
+ if (typeof json !== 'object' || json === null) {
1502
+ return null;
1503
+ }
1504
+ const record = json as Record<string, unknown>;
1505
+ const preferredKeys = ['data', 'rows', 'results', 'items'];
1506
+ for (const key of preferredKeys) {
1507
+ if (Array.isArray(record[key])) {
1508
+ return { rows: record[key] as unknown[], source: `$.${key}` };
1509
+ }
1510
+ }
1511
+ return null;
1512
+ }
1513
+
1514
+ async function enrichReplayWithSchema(tabId: number, response: PageFetchResponse): Promise<PageFetchResponse> {
1515
+ const candidate = extractReplayRowsCandidate(response.json);
1516
+ if (!candidate || candidate.rows.length === 0) {
1517
+ return response;
1518
+ }
1519
+
1520
+ const firstRow = candidate.rows[0];
1521
+ const tablesResult = (await forwardContentRpc(tabId, 'table.list', {})) as { tables?: TableHandle[] };
1522
+ const tables = Array.isArray(tablesResult.tables) ? tablesResult.tables : [];
1523
+ if (tables.length === 0) {
1524
+ return response;
1525
+ }
1526
+
1527
+ const schemas: Array<{ table: TableHandle; schema: TableSchema }> = [];
1528
+ for (const table of tables) {
1529
+ const schemaResult = (await forwardContentRpc(tabId, 'table.schema', { table: table.id })) as {
1530
+ table?: TableHandle;
1531
+ schema?: TableSchema;
1532
+ };
1533
+ if (schemaResult.schema && Array.isArray(schemaResult.schema.columns)) {
1534
+ schemas.push({ table: schemaResult.table ?? table, schema: schemaResult.schema });
1535
+ }
1536
+ }
1537
+
1538
+ if (schemas.length === 0) {
1539
+ return response;
1540
+ }
1541
+
1542
+ if (Array.isArray(firstRow)) {
1543
+ const matchingSchema = schemas.find(({ schema }) => schema.columns.length === firstRow.length) ?? schemas[0];
1544
+ if (!matchingSchema) {
1545
+ return response;
1546
+ }
1547
+ const mappedRows = candidate.rows
1548
+ .filter((row): row is unknown[] => Array.isArray(row))
1549
+ .map((row) => {
1550
+ const mapped: Record<string, unknown> = {};
1551
+ matchingSchema.schema.columns.forEach((column, index) => {
1552
+ mapped[column.label] = row[index];
1553
+ });
1554
+ return mapped;
1555
+ });
1556
+ return {
1557
+ ...response,
1558
+ table: matchingSchema.table,
1559
+ schema: matchingSchema.schema,
1560
+ mappedRows,
1561
+ mappingSource: candidate.source
1562
+ };
1563
+ }
1564
+
1565
+ if (typeof firstRow === 'object' && firstRow !== null) {
1566
+ return {
1567
+ ...response,
1568
+ mappedRows: candidate.rows.filter((row): row is Record<string, unknown> => typeof row === 'object' && row !== null),
1569
+ mappingSource: candidate.source
1570
+ };
1571
+ }
1572
+
1573
+ return response;
1574
+ }
1086
1575
 
1087
1576
  async function handleRequest(request: CliRequest): Promise<unknown> {
1088
1577
  const params = request.params ?? {};
1089
- const target = {
1090
- tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
1091
- workspaceId: typeof params.workspaceId === 'string' ? params.workspaceId : undefined
1092
- };
1578
+ const target = {
1579
+ tabId: typeof params.tabId === 'number' ? params.tabId : undefined,
1580
+ bindingId: typeof params.bindingId === 'string' ? params.bindingId : undefined
1581
+ };
1093
1582
 
1094
1583
  const rpcForwardMethods = new Set([
1095
1584
  'page.title',
@@ -1191,64 +1680,93 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1191
1680
  await chrome.tabs.remove(tabId);
1192
1681
  return { ok: true };
1193
1682
  }
1194
- case 'workspace.ensure': {
1683
+ case 'sessionBinding.ensure': {
1195
1684
  return preserveHumanFocus(params.focus !== true, async () => {
1196
- const result = await bindingManager.ensureWorkspace({
1197
- workspaceId: String(params.workspaceId ?? ''),
1685
+ const result = await bindingManager.ensureBinding({
1686
+ bindingId: String(params.bindingId ?? ''),
1198
1687
  focus: params.focus === true,
1199
1688
  initialUrl: typeof params.url === 'string' ? params.url : undefined
1200
1689
  });
1201
- for (const tab of result.workspace.tabs) {
1690
+ for (const tab of result.binding.tabs) {
1202
1691
  void ensureNetworkDebugger(tab.id).catch(() => undefined);
1203
1692
  }
1204
- return result;
1693
+ return {
1694
+ browser: result.binding,
1695
+ created: result.created,
1696
+ repaired: result.repaired,
1697
+ repairActions: result.repairActions
1698
+ };
1205
1699
  });
1206
1700
  }
1207
- case 'workspace.info': {
1208
- return {
1209
- workspace: await bindingManager.getWorkspaceInfo(String(params.workspaceId ?? ''))
1210
- };
1211
- }
1212
- case 'workspace.openTab': {
1701
+ case 'sessionBinding.info': {
1702
+ return {
1703
+ browser: await bindingManager.getBindingInfo(String(params.bindingId ?? ''))
1704
+ };
1705
+ }
1706
+ case 'sessionBinding.openTab': {
1213
1707
  const expectedUrl = typeof params.url === 'string' ? params.url : undefined;
1214
1708
  const opened = await preserveHumanFocus(params.focus !== true, async () => {
1215
1709
  return await bindingManager.openTab({
1216
- workspaceId: String(params.workspaceId ?? ''),
1217
- url: expectedUrl,
1218
- active: params.active === true,
1710
+ bindingId: String(params.bindingId ?? ''),
1711
+ url: expectedUrl,
1712
+ active: params.active === true,
1219
1713
  focus: params.focus === true
1220
1714
  });
1221
1715
  });
1222
- const finalized = await finalizeOpenedWorkspaceTab(opened, expectedUrl);
1716
+ const finalized = await finalizeOpenedSessionBindingTab(opened, expectedUrl);
1223
1717
  void ensureNetworkDebugger(finalized.tab.id).catch(() => undefined);
1224
- return finalized;
1718
+ return {
1719
+ browser: finalized.binding,
1720
+ tab: finalized.tab
1721
+ };
1225
1722
  }
1226
- case 'workspace.listTabs': {
1227
- return await bindingManager.listTabs(String(params.workspaceId ?? ''));
1228
- }
1229
- case 'workspace.getActiveTab': {
1230
- return await bindingManager.getActiveTab(String(params.workspaceId ?? ''));
1231
- }
1232
- case 'workspace.setActiveTab': {
1233
- const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.workspaceId ?? ''));
1723
+ case 'sessionBinding.listTabs': {
1724
+ const listed = await bindingManager.listTabs(String(params.bindingId ?? ''));
1725
+ return {
1726
+ browser: listed.binding,
1727
+ tabs: listed.tabs
1728
+ };
1729
+ }
1730
+ case 'sessionBinding.getActiveTab': {
1731
+ const active = await bindingManager.getActiveTab(String(params.bindingId ?? ''));
1732
+ return {
1733
+ browser: active.binding,
1734
+ tab: active.tab
1735
+ };
1736
+ }
1737
+ case 'sessionBinding.setActiveTab': {
1738
+ const result = await bindingManager.setActiveTab(Number(params.tabId), String(params.bindingId ?? ''));
1234
1739
  void ensureNetworkDebugger(result.tab.id).catch(() => undefined);
1235
- return result;
1740
+ return {
1741
+ browser: result.binding,
1742
+ tab: result.tab
1743
+ };
1744
+ }
1745
+ case 'sessionBinding.focus': {
1746
+ const result = await bindingManager.focus(String(params.bindingId ?? ''));
1747
+ return {
1748
+ ok: true,
1749
+ browser: result.binding
1750
+ };
1751
+ }
1752
+ case 'sessionBinding.reset': {
1753
+ return await preserveHumanFocus(params.focus !== true, async () => {
1754
+ const result = await bindingManager.reset({
1755
+ bindingId: String(params.bindingId ?? ''),
1756
+ focus: params.focus === true,
1757
+ initialUrl: typeof params.url === 'string' ? params.url : undefined
1758
+ });
1759
+ return {
1760
+ browser: result.binding,
1761
+ created: result.created,
1762
+ repaired: result.repaired,
1763
+ repairActions: result.repairActions
1764
+ };
1765
+ });
1766
+ }
1767
+ case 'sessionBinding.close': {
1768
+ return await bindingManager.close(String(params.bindingId ?? ''));
1236
1769
  }
1237
- case 'workspace.focus': {
1238
- return await bindingManager.focus(String(params.workspaceId ?? ''));
1239
- }
1240
- case 'workspace.reset': {
1241
- return await preserveHumanFocus(params.focus !== true, async () => {
1242
- return await bindingManager.reset({
1243
- workspaceId: String(params.workspaceId ?? ''),
1244
- focus: params.focus === true,
1245
- initialUrl: typeof params.url === 'string' ? params.url : undefined
1246
- });
1247
- });
1248
- }
1249
- case 'workspace.close': {
1250
- return await bindingManager.close(String(params.workspaceId ?? ''));
1251
- }
1252
1770
  case 'page.goto': {
1253
1771
  return await preserveHumanFocus(typeof target.tabId !== 'number', async () => {
1254
1772
  const tab = await withTab(target, {
@@ -1556,7 +2074,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1556
2074
  if (!first) {
1557
2075
  throw toError('E_EXECUTION', 'network replay returned no response payload');
1558
2076
  }
1559
- return first;
2077
+ return params.withSchema === 'auto' && params.mode === 'json' ? await enrichReplayWithSchema(tab.id!, first) : first;
1560
2078
  });
1561
2079
  }
1562
2080
  case 'page.freshness': {
@@ -1632,15 +2150,17 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1632
2150
  const tab = await withTab(target);
1633
2151
  await ensureNetworkDebugger(tab.id!).catch(() => undefined);
1634
2152
  const inspection = await collectPageInspection(tab.id!, params);
2153
+ const pageDataCandidates = await probePageDataCandidatesForTab(tab.id!, inspection);
1635
2154
  const network = listNetworkEntries(tab.id!, { limit: 10 });
1636
2155
  return {
1637
2156
  suspiciousGlobals: inspection.suspiciousGlobals ?? [],
1638
2157
  tables: inspection.tables ?? [],
1639
2158
  visibleTimestamps: inspection.visibleTimestamps ?? [],
1640
2159
  inlineTimestamps: inspection.inlineTimestamps ?? [],
2160
+ pageDataCandidates,
1641
2161
  recentNetwork: network,
1642
2162
  recommendedNextSteps: [
1643
- 'bak page extract --path table_data',
2163
+ 'bak page extract --path table_data --resolver auto',
1644
2164
  'bak network search --pattern table_data',
1645
2165
  'bak page freshness'
1646
2166
  ]
@@ -1657,6 +2177,7 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1657
2177
  lastMutationAt: inspection.lastMutationAt ?? null,
1658
2178
  timers: inspection.timers ?? { timeouts: 0, intervals: 0 },
1659
2179
  networkCount: network.length,
2180
+ networkCadence: summarizeNetworkCadence(network),
1660
2181
  recentNetwork: network.slice(0, 10)
1661
2182
  };
1662
2183
  });
@@ -1668,8 +2189,13 @@ async function handleRequest(request: CliRequest): Promise<unknown> {
1668
2189
  return {
1669
2190
  ...freshness,
1670
2191
  lagMs:
1671
- typeof freshness.latestNetworkTimestamp === 'number' && typeof freshness.latestInlineDataTimestamp === 'number'
1672
- ? Math.max(0, freshness.latestNetworkTimestamp - freshness.latestInlineDataTimestamp)
2192
+ typeof freshness.latestNetworkTimestamp === 'number' &&
2193
+ typeof (freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp) === 'number'
2194
+ ? Math.max(
2195
+ 0,
2196
+ freshness.latestNetworkTimestamp -
2197
+ (freshness.latestPageDataTimestamp ?? freshness.latestInlineDataTimestamp ?? freshness.latestNetworkTimestamp)
2198
+ )
1673
2199
  : null
1674
2200
  };
1675
2201
  });
@@ -1782,7 +2308,7 @@ async function connectWebSocket(): Promise<void> {
1782
2308
  ws?.send(JSON.stringify({
1783
2309
  type: 'hello',
1784
2310
  role: 'extension',
1785
- version: '0.6.0',
2311
+ version: '0.6.1',
1786
2312
  ts: Date.now()
1787
2313
  }));
1788
2314
  });
@@ -1824,53 +2350,53 @@ async function connectWebSocket(): Promise<void> {
1824
2350
 
1825
2351
  chrome.tabs.onRemoved.addListener((tabId) => {
1826
2352
  dropNetworkCapture(tabId);
1827
- void listWorkspaceStates().then(async (states) => {
1828
- for (const state of states) {
2353
+ void mutateSessionBindingStateMap((stateMap) => {
2354
+ for (const [bindingId, state] of Object.entries(stateMap)) {
1829
2355
  if (!state.tabIds.includes(tabId)) {
1830
2356
  continue;
1831
2357
  }
1832
- const nextTabIds = state.tabIds.filter((id) => id !== tabId);
1833
- await saveWorkspaceState({
1834
- ...state,
1835
- tabIds: nextTabIds,
1836
- activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
1837
- primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
1838
- });
1839
- }
1840
- });
1841
- });
1842
-
1843
- chrome.tabs.onActivated.addListener((activeInfo) => {
1844
- void listWorkspaceStates().then(async (states) => {
1845
- for (const state of states) {
1846
- if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
1847
- continue;
1848
- }
1849
- await saveWorkspaceState({
1850
- ...state,
1851
- activeTabId: activeInfo.tabId
1852
- });
1853
- }
1854
- });
1855
- });
1856
-
1857
- chrome.windows.onRemoved.addListener((windowId) => {
1858
- void listWorkspaceStates().then(async (states) => {
1859
- for (const state of states) {
1860
- if (state.windowId !== windowId) {
1861
- continue;
1862
- }
1863
- await saveWorkspaceState({
1864
- ...state,
1865
- windowId: null,
1866
- groupId: null,
1867
- tabIds: [],
1868
- activeTabId: null,
1869
- primaryTabId: null
1870
- });
1871
- }
1872
- });
1873
- });
2358
+ const nextTabIds = state.tabIds.filter((id) => id !== tabId);
2359
+ stateMap[bindingId] = {
2360
+ ...state,
2361
+ tabIds: nextTabIds,
2362
+ activeTabId: state.activeTabId === tabId ? null : state.activeTabId,
2363
+ primaryTabId: state.primaryTabId === tabId ? null : state.primaryTabId
2364
+ };
2365
+ }
2366
+ });
2367
+ });
2368
+
2369
+ chrome.tabs.onActivated.addListener((activeInfo) => {
2370
+ void mutateSessionBindingStateMap((stateMap) => {
2371
+ for (const [bindingId, state] of Object.entries(stateMap)) {
2372
+ if (state.windowId !== activeInfo.windowId || !state.tabIds.includes(activeInfo.tabId)) {
2373
+ continue;
2374
+ }
2375
+ stateMap[bindingId] = {
2376
+ ...state,
2377
+ activeTabId: activeInfo.tabId
2378
+ };
2379
+ }
2380
+ });
2381
+ });
2382
+
2383
+ chrome.windows.onRemoved.addListener((windowId) => {
2384
+ void mutateSessionBindingStateMap((stateMap) => {
2385
+ for (const [bindingId, state] of Object.entries(stateMap)) {
2386
+ if (state.windowId !== windowId) {
2387
+ continue;
2388
+ }
2389
+ stateMap[bindingId] = {
2390
+ ...state,
2391
+ windowId: null,
2392
+ groupId: null,
2393
+ tabIds: [],
2394
+ activeTabId: null,
2395
+ primaryTabId: null
2396
+ };
2397
+ }
2398
+ });
2399
+ });
1874
2400
 
1875
2401
  chrome.runtime.onInstalled.addListener(() => {
1876
2402
  void setConfig({ port: DEFAULT_PORT, debugRichText: false });