tabctl 0.6.0-rc.7 → 0.6.0-rc.8

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/README.md CHANGED
@@ -170,7 +170,7 @@ tabctl query 'query { inspectTabs(tabIds: [456], signals: ["page-meta"]) { entri
170
170
  tabctl query 'query { inspectTabs(windowId: 123, selectors: [{ name: "prices", selector: ".price", attr: "text", all: true }, { name: "buy_now_visible", selector: "button.buy-now", attr: "visible" }, { name: "buy_now_style", selector: "button.buy-now", attr: "styles", styleProps: ["color", "background-color"] }, { name: "review_count", selector: ".review", attr: "count" }, { name: "email_value", selector: "input[type=email]", attr: "value" }]) { entries { tabId url signals { name valueJson } } } }'
171
171
 
172
172
  # Read tab content as Markdown (Kreuzberg preprocessing by default)
173
- tabctl query 'query { readTabs(windowId: 123, extract: true, maxChars: 30000) { entries { tabId title url markdown chars truncated extracted status emptyReason diagnostics { sourceHtmlChars sourceTextChars documentReadyState truncatedHtml } error } } }'
173
+ tabctl query 'query { readTabs(windowId: 123, extract: true, maxChars: 30000) { entries { tabId title url markdown chars truncated extracted cached status emptyReason diagnostics { source sourceHtmlChars sourceTextChars documentReadyState truncatedHtml cachedAt cacheAgeMs } error } } }'
174
174
 
175
175
  # Generate reports
176
176
  tabctl query '{ reportTabs(windowId: 123) { entries { tabId title url description } } }'
@@ -427,7 +427,8 @@ Notes:
427
427
  - Browser reads and mutations now go through GraphQL via `tabctl query`.
428
428
  - Reports include short descriptions from page metadata and a fallback snippet.
429
429
  - `inspectTabs` supports `page-meta` plus selector reads with `all`, `text`, `textMode`, `styleProps`, and attrs such as `html`, `value`, `count`, `box`, `styles`, `visible`, `enabled`, and `checked`.
430
- - `readTabs` converts main-frame page HTML to Markdown with Kreuzberg `html-to-markdown`; per-tab `status`, `emptyReason`, `diagnostics`, and `error` distinguish empty pages, unsupported URLs, injection failures, conversion failures, and timeouts.
430
+ - `readTabs` converts main-frame page HTML to Markdown with Kreuzberg `html-to-markdown`; per-tab `status`, `emptyReason`, `diagnostics`, and `error` distinguish empty pages, unsupported URLs, injection failures, conversion failures, timeouts, and cached fallbacks (`status: CACHED`, `cached: true`, `diagnostics.source: "cache"`).
431
+ - Cached fallbacks use a bounded profile-local open-tab HTML cache refreshed after successful reads, active tab switches, and quiescent active pages; diagnostics include cache age and match mode when cached content is used.
431
432
  - `captureScreenshots` returns tile metadata and image data from GraphQL.
432
433
  - `undoAction` accepts either an explicit `txid` or `latest: true`.
433
434
  - `tabctl history --json` returns a top-level JSON array.
@@ -341,7 +341,8 @@
341
341
  extractPageHtml: () => extractPageHtml,
342
342
  extractPageMarkdown: () => extractPageMarkdown,
343
343
  extractPageMeta: () => extractPageMeta,
344
- extractSelectorSignal: () => extractSelectorSignal
344
+ extractSelectorSignal: () => extractSelectorSignal,
345
+ probePageQuiescence: () => probePageQuiescence
345
346
  });
346
347
  function delay(ms) {
347
348
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -485,6 +486,114 @@
485
486
  error: null
486
487
  };
487
488
  }
489
+ async function probePageQuiescence(tabId, timeoutMs, sampleWindowMs) {
490
+ const result = await executeWithTimeoutDetailed(tabId, timeoutMs, async (rawWindowMs) => {
491
+ const startedAt = Date.now();
492
+ const windowMs = Math.max(50, Math.min(Number(rawWindowMs) || 350, 1500));
493
+ const htmlCap = 25e4;
494
+ const sample = () => {
495
+ const root = document.documentElement;
496
+ const bodyText = document.body?.innerText || root?.textContent || "";
497
+ const html = root?.outerHTML || "";
498
+ const resources = typeof performance !== "undefined" && typeof performance.getEntriesByType === "function" ? performance.getEntriesByType("resource").length : null;
499
+ return {
500
+ textChars: bodyText.length,
501
+ htmlChars: Math.min(html.length, htmlCap),
502
+ domElements: document.getElementsByTagName("*").length,
503
+ resourceCount: resources
504
+ };
505
+ };
506
+ const waitForIdleWindow = () => new Promise((resolve) => {
507
+ const win = window;
508
+ const done = () => setTimeout(resolve, windowMs);
509
+ if (typeof win.requestIdleCallback === "function") {
510
+ let resolved = false;
511
+ const finish = () => {
512
+ if (resolved) {
513
+ return;
514
+ }
515
+ resolved = true;
516
+ done();
517
+ };
518
+ const fallback = setTimeout(finish, windowMs + 100);
519
+ win.requestIdleCallback(() => {
520
+ clearTimeout(fallback);
521
+ finish();
522
+ }, { timeout: windowMs });
523
+ return;
524
+ }
525
+ setTimeout(resolve, windowMs);
526
+ });
527
+ try {
528
+ const readyState = document.readyState;
529
+ if (readyState !== "interactive" && readyState !== "complete") {
530
+ return {
531
+ quiet: false,
532
+ reason: "not-ready",
533
+ documentReadyState: readyState,
534
+ before: null,
535
+ after: null,
536
+ elapsedMs: Date.now() - startedAt,
537
+ error: null
538
+ };
539
+ }
540
+ const before = sample();
541
+ await waitForIdleWindow();
542
+ const after = sample();
543
+ const stable = before.textChars === after.textChars && before.htmlChars === after.htmlChars && before.domElements === after.domElements && before.resourceCount === after.resourceCount;
544
+ return {
545
+ quiet: stable,
546
+ reason: stable ? "stable" : "changed",
547
+ documentReadyState: document.readyState,
548
+ before,
549
+ after,
550
+ elapsedMs: Date.now() - startedAt,
551
+ error: null
552
+ };
553
+ } catch (err) {
554
+ return {
555
+ quiet: false,
556
+ reason: "probe-error",
557
+ documentReadyState: typeof document !== "undefined" ? document.readyState : null,
558
+ before: null,
559
+ after: null,
560
+ elapsedMs: Date.now() - startedAt,
561
+ error: err instanceof Error ? err.message : String(err)
562
+ };
563
+ }
564
+ }, [sampleWindowMs]);
565
+ if (result.kind === "timeout") {
566
+ return {
567
+ quiet: false,
568
+ reason: "timed-out",
569
+ documentReadyState: null,
570
+ before: null,
571
+ after: null,
572
+ elapsedMs: timeoutMs,
573
+ error: `Timed out after ${timeoutMs}ms`
574
+ };
575
+ }
576
+ if (result.kind === "error") {
577
+ return {
578
+ quiet: false,
579
+ reason: "injection-error",
580
+ documentReadyState: null,
581
+ before: null,
582
+ after: null,
583
+ elapsedMs: 0,
584
+ error: result.message
585
+ };
586
+ }
587
+ return result.value ?? {
588
+ quiet: false,
589
+ reason: "no-result",
590
+ documentReadyState: null,
591
+ before: null,
592
+ after: null,
593
+ elapsedMs: 0,
594
+ error: "Content script returned no quiescence probe payload"
595
+ };
596
+ }
488
597
  async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLength) {
489
598
  if (!specs.length) {
490
599
  return null;
@@ -674,6 +783,18 @@
674
783
  var RECONNECT_ALARM = "tabctl-reconnect";
675
784
  var KEEPALIVE_INTERVAL_MINUTES = 1;
676
785
  var BROWSER_STATE_SYNC_DEBOUNCE_MS = 750;
786
+ var ACTIVE_PAGE_CACHE_DEBOUNCE_MS = 1e3;
787
+ var ACTIVE_PAGE_CACHE_TIMEOUT_MS = 5e3;
788
+ var MAX_PAGE_HTML_CHARS = 15e5;
789
+ var ACTIVE_PAGE_CACHE_MAX_HTML_CHARS = MAX_PAGE_HTML_CHARS;
790
+ var ACTIVE_PAGE_CACHE_QUIESCENT_DELAY_MS = 6e3;
791
+ var ACTIVE_PAGE_CACHE_QUIESCENT_RETRY_MS = 1e3;
792
+ var ACTIVE_PAGE_CACHE_QUIESCENT_TIMEOUT_MS = 2500;
793
+ var ACTIVE_PAGE_CACHE_QUIESCENT_SAMPLE_MS = 350;
794
+ var ACTIVE_PAGE_CACHE_QUIESCENT_COOLDOWN_MS = 3e4;
795
+ var ACTIVE_PAGE_CACHE_STATUS_TIMEOUT_MS = 3e4;
796
+ var CACHE_AVAILABLE_BADGE_TEXT = "C";
797
+ var CACHE_AVAILABLE_BADGE_COLOR = "#2da44e";
677
798
  var RECONNECT_INITIAL_DELAY_MS = 250;
678
799
  var RECONNECT_MAX_DELAY_MS = 3e4;
679
800
  var RECONNECT_ALARM_MIN_DELAY_MS = 3e4;
@@ -701,6 +822,18 @@
701
822
  incognitoTabIds: /* @__PURE__ */ new Set(),
702
823
  incognitoGroupIds: /* @__PURE__ */ new Set()
703
824
  };
825
+ var activePageCache = {
826
+ nextId: 1,
827
+ timer: null,
828
+ pending: null,
829
+ quiescentTimer: null,
830
+ quiescentPending: null,
831
+ inFlightKeys: /* @__PURE__ */ new Set(),
832
+ lastCapturedKey: null,
833
+ lastQuiescentCapturedKey: null,
834
+ lastQuiescentCapturedAt: 0,
835
+ statusRequests: /* @__PURE__ */ new Map()
836
+ };
704
837
  function log(...args) {
705
838
  console.log("[tabctl]", ...args);
706
839
  }
@@ -781,6 +914,9 @@
781
914
  state.port = port;
782
915
  resetReconnectBackoffAfterStablePort(port);
783
916
  port.onMessage.addListener((message) => {
917
+ if (handlePageCacheStatusMessage(message)) {
918
+ return;
919
+ }
784
920
  void handleNativeMessage(port, message);
785
921
  });
786
922
  port.onDisconnect.addListener(() => {
@@ -793,11 +929,13 @@
793
929
  if (state.port === port) {
794
930
  state.port = null;
795
931
  }
932
+ activePageCache.statusRequests.clear();
796
933
  clearReconnectStableTimer();
797
934
  scheduleReconnect("disconnect");
798
935
  });
799
936
  log("Native host connected");
800
937
  queueBrowserStateSync("startup");
938
+ void refreshActivePageCacheIndicator("connectNative");
801
939
  } catch (error) {
802
940
  log("Native host connection failed", error);
803
941
  scheduleReconnect("connect-failed");
@@ -808,6 +946,360 @@
808
946
  browserState.nextId += 1;
809
947
  return `browser-state-${Date.now()}-${id}`;
810
948
  }
949
+ function nextActivePageCacheId() {
950
+ const id = activePageCache.nextId;
951
+ activePageCache.nextId += 1;
952
+ return `page-cache-capture-${Date.now()}-${id}`;
953
+ }
954
+ function nextActivePageCacheStatusId() {
955
+ const id = activePageCache.nextId;
956
+ activePageCache.nextId += 1;
957
+ return `page-cache-status-${Date.now()}-${id}`;
958
+ }
959
+ function trackPageCacheStatusRequest(id, tabId, url) {
960
+ activePageCache.statusRequests.set(id, { tabId, url });
961
+ setTimeout(() => {
962
+ activePageCache.statusRequests.delete(id);
963
+ }, ACTIVE_PAGE_CACHE_STATUS_TIMEOUT_MS);
964
+ }
965
+ function isScriptableUrl(url) {
966
+ const lower = url.toLowerCase();
967
+ return lower.startsWith("http://") || lower.startsWith("https://");
968
+ }
969
+ function activePageCacheKey(tab, url) {
970
+ return `${tab.id}:${url}`;
971
+ }
972
+ function activePageCacheUrl(tab) {
973
+ return tab.url || tab.pendingUrl || "";
974
+ }
975
+ async function tabUrlMatches(tabId, expectedUrl) {
976
+ try {
977
+ return activePageCacheUrl(await chrome.tabs.get(tabId)) === expectedUrl;
978
+ } catch {
979
+ return false;
980
+ }
981
+ }
982
+ async function clearCacheAvailableIndicator(tabId) {
983
+ try {
984
+ await chrome.action?.setBadgeText?.({ tabId, text: "" });
985
+ await chrome.action?.setTitle?.({ tabId, title: "Tab Control" });
986
+ } catch (error) {
987
+ log("clear cache indicator failed", { tabId, error });
988
+ }
989
+ }
990
+ async function setCacheAvailableIndicator(tabId, expectedUrl) {
991
+ try {
992
+ const tab = await chrome.tabs.get(tabId);
993
+ if (!isEligibleActivePageCacheTab(tab) || activePageCacheUrl(tab) !== expectedUrl) {
994
+ await clearCacheAvailableIndicator(tabId);
995
+ return;
996
+ }
997
+ await chrome.action?.setBadgeBackgroundColor?.({ tabId, color: CACHE_AVAILABLE_BADGE_COLOR });
998
+ await chrome.action?.setBadgeText?.({ tabId, text: CACHE_AVAILABLE_BADGE_TEXT });
999
+ await chrome.action?.setTitle?.({ tabId, title: "Tab Control - page cache available" });
1000
+ } catch (error) {
1001
+ log("set cache indicator failed", { tabId, error });
1002
+ }
1003
+ }
1004
+ async function applyPageCacheStatus(tabId, expectedUrl, available) {
1005
+ try {
1006
+ const tab = await chrome.tabs.get(tabId);
1007
+ if (activePageCacheUrl(tab) !== expectedUrl) {
1008
+ return;
1009
+ }
1010
+ if (!isEligibleActivePageCacheTab(tab) || !available) {
1011
+ await clearCacheAvailableIndicator(tabId);
1012
+ return;
1013
+ }
1014
+ await setCacheAvailableIndicator(tabId, expectedUrl);
1015
+ } catch {
1016
+ await clearCacheAvailableIndicator(tabId);
1017
+ }
1018
+ }
1019
+ function handlePageCacheStatusMessage(message) {
1020
+ if (!message || typeof message !== "object" || message.action) {
1021
+ return false;
1022
+ }
1023
+ const requestId = typeof message.id === "string" ? message.id : "";
1024
+ const pending = activePageCache.statusRequests.get(requestId);
1025
+ if (!pending) {
1026
+ return false;
1027
+ }
1028
+ activePageCache.statusRequests.delete(requestId);
1029
+ const data = message.data && typeof message.data === "object" ? message.data : {};
1030
+ const tabId = typeof data.tabId === "number" ? data.tabId : pending.tabId;
1031
+ const url = typeof data.url === "string" ? data.url : pending.url;
1032
+ const available = message.ok === true && data.available === true && tabId === pending.tabId && url === pending.url;
1033
+ void applyPageCacheStatus(pending.tabId, pending.url, available);
1034
+ return true;
1035
+ }
1036
+ function requestPageCacheStatus(tab, reason) {
1037
+ const url = activePageCacheUrl(tab);
1038
+ if (typeof tab.id !== "number" || !isEligibleActivePageCacheTab(tab)) {
1039
+ if (typeof tab.id === "number") {
1040
+ void clearCacheAvailableIndicator(tab.id);
1041
+ }
1042
+ return;
1043
+ }
1044
+ const port = state.port;
1045
+ if (!port) {
1046
+ connectNative();
1047
+ void clearCacheAvailableIndicator(tab.id);
1048
+ return;
1049
+ }
1050
+ const id = nextActivePageCacheStatusId();
1051
+ trackPageCacheStatusRequest(id, tab.id, url);
1052
+ port.postMessage({
1053
+ id,
1054
+ action: "page-cache-status",
1055
+ ok: true,
1056
+ data: {
1057
+ reason,
1058
+ requestedAt: Date.now(),
1059
+ tab: {
1060
+ tabId: tab.id,
1061
+ url,
1062
+ incognito: tab.incognito || false,
1063
+ discarded: tab.discarded || false,
1064
+ status: tab.status || null
1065
+ }
1066
+ }
1067
+ });
1068
+ }
1069
+ async function requestPageCacheStatusForTab(tabId, reason) {
1070
+ try {
1071
+ requestPageCacheStatus(await chrome.tabs.get(tabId), reason);
1072
+ } catch {
1073
+ await clearCacheAvailableIndicator(tabId);
1074
+ }
1075
+ }
1076
+ async function refreshActivePageCacheIndicator(reason) {
1077
+ try {
1078
+ const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
1079
+ if (tab) {
1080
+ requestPageCacheStatus(tab, reason);
1081
+ }
1082
+ } catch (error) {
1083
+ log("active page cache indicator refresh failed", { reason, error });
1084
+ }
1085
+ }
1086
+ function urlMismatchPageHtmlResponse(expectedUrl) {
1087
+ return {
1088
+ status: "URL_MISMATCH",
1089
+ html: "",
1090
+ sourceHtmlChars: 0,
1091
+ sourceTextChars: 0,
1092
+ documentReadyState: null,
1093
+ truncatedHtml: false,
1094
+ error: `Tab URL changed before page HTML extraction completed: expected ${expectedUrl}`
1095
+ };
1096
+ }
1097
+ function isEligibleActivePageCacheTab(tab) {
1098
+ const url = activePageCacheUrl(tab);
1099
+ return typeof tab.id === "number" && Boolean(url) && tab.incognito !== true && !browserState.incognitoTabIds.has(tab.id) && (typeof tab.windowId !== "number" || !browserState.incognitoWindowIds.has(tab.windowId)) && tab.discarded !== true && tab.status !== "loading" && isScriptableUrl(url);
1100
+ }
1101
+ function clearPendingActivePageCacheCapture() {
1102
+ if (activePageCache.timer) {
1103
+ clearTimeout(activePageCache.timer);
1104
+ activePageCache.timer = null;
1105
+ }
1106
+ activePageCache.pending = null;
1107
+ }
1108
+ function clearPendingQuiescentActivePageCacheCapture() {
1109
+ if (activePageCache.quiescentTimer) {
1110
+ clearTimeout(activePageCache.quiescentTimer);
1111
+ activePageCache.quiescentTimer = null;
1112
+ }
1113
+ activePageCache.quiescentPending = null;
1114
+ }
1115
+ function isQuiescentCaptureCoolingDown(key) {
1116
+ return activePageCache.lastQuiescentCapturedKey === key && Date.now() - activePageCache.lastQuiescentCapturedAt < ACTIVE_PAGE_CACHE_QUIESCENT_COOLDOWN_MS;
1117
+ }
1118
+ function scheduleQuiescentActivePageCacheCapture(tab, reason, key) {
1119
+ if (isQuiescentCaptureCoolingDown(key)) {
1120
+ return;
1121
+ }
1122
+ clearPendingQuiescentActivePageCacheCapture();
1123
+ activePageCache.quiescentPending = {
1124
+ tabId: tab.id,
1125
+ key,
1126
+ reason: `${reason}:quiescent`,
1127
+ attempts: 0
1128
+ };
1129
+ activePageCache.quiescentTimer = setTimeout(() => {
1130
+ activePageCache.quiescentTimer = null;
1131
+ const pending = activePageCache.quiescentPending;
1132
+ activePageCache.quiescentPending = null;
1133
+ if (pending) {
1134
+ void captureQuiescentActivePageCache(pending);
1135
+ }
1136
+ }, ACTIVE_PAGE_CACHE_QUIESCENT_DELAY_MS);
1137
+ }
1138
+ function scheduleActivePageCacheCapture(tab, reason) {
1139
+ if (!isEligibleActivePageCacheTab(tab)) {
1140
+ if (typeof tab.id === "number") {
1141
+ void clearCacheAvailableIndicator(tab.id);
1142
+ }
1143
+ clearPendingActivePageCacheCapture();
1144
+ clearPendingQuiescentActivePageCacheCapture();
1145
+ return;
1146
+ }
1147
+ requestPageCacheStatus(tab, reason);
1148
+ const url = activePageCacheUrl(tab);
1149
+ const key = activePageCacheKey(tab, url);
1150
+ scheduleQuiescentActivePageCacheCapture(tab, reason, key);
1151
+ if (activePageCache.inFlightKeys.has(key) || activePageCache.lastCapturedKey === key) {
1152
+ return;
1153
+ }
1154
+ clearPendingActivePageCacheCapture();
1155
+ activePageCache.pending = { tab, reason, key };
1156
+ activePageCache.timer = setTimeout(() => {
1157
+ activePageCache.timer = null;
1158
+ const pending = activePageCache.pending;
1159
+ activePageCache.pending = null;
1160
+ if (pending) {
1161
+ void captureActivePageCache(pending.tab, pending.reason, pending.key);
1162
+ }
1163
+ }, ACTIVE_PAGE_CACHE_DEBOUNCE_MS);
1164
+ }
1165
+ async function scheduleActivePageCacheCaptureForTab(tabId, reason) {
1166
+ try {
1167
+ scheduleActivePageCacheCapture(await chrome.tabs.get(tabId), reason);
1168
+ } catch (error) {
1169
+ log("active page cache tab lookup failed", { tabId, reason, error });
1170
+ await clearCacheAvailableIndicator(tabId);
1171
+ }
1172
+ }
1173
+ async function scheduleActivePageCacheCaptureForWindow(windowId, reason) {
1174
+ if (windowId === chrome.windows.WINDOW_ID_NONE) {
1175
+ return;
1176
+ }
1177
+ try {
1178
+ const [tab] = await chrome.tabs.query({ active: true, windowId });
1179
+ if (tab) {
1180
+ scheduleActivePageCacheCapture(tab, reason);
1181
+ }
1182
+ } catch (error) {
1183
+ log("active page cache window lookup failed", { windowId, reason, error });
1184
+ }
1185
+ }
1186
+ async function currentActivePageCacheTab(tabId, key) {
1187
+ try {
1188
+ const tab = await chrome.tabs.get(tabId);
1189
+ const url = activePageCacheUrl(tab);
1190
+ if (!tab.active || !isEligibleActivePageCacheTab(tab) || activePageCacheKey(tab, url) !== key) {
1191
+ return null;
1192
+ }
1193
+ return tab;
1194
+ } catch {
1195
+ return null;
1196
+ }
1197
+ }
1198
+ async function rescheduleQuiescentActivePageCacheCapture(pending) {
1199
+ if (pending.attempts >= 2 || isQuiescentCaptureCoolingDown(pending.key)) {
1200
+ return;
1201
+ }
1202
+ activePageCache.quiescentPending = { ...pending, attempts: pending.attempts + 1 };
1203
+ activePageCache.quiescentTimer = setTimeout(() => {
1204
+ activePageCache.quiescentTimer = null;
1205
+ const retry = activePageCache.quiescentPending;
1206
+ activePageCache.quiescentPending = null;
1207
+ if (retry) {
1208
+ void captureQuiescentActivePageCache(retry);
1209
+ }
1210
+ }, ACTIVE_PAGE_CACHE_QUIESCENT_RETRY_MS);
1211
+ }
1212
+ async function captureQuiescentActivePageCache(pending) {
1213
+ if (isQuiescentCaptureCoolingDown(pending.key)) {
1214
+ return;
1215
+ }
1216
+ if (activePageCache.inFlightKeys.has(pending.key)) {
1217
+ await rescheduleQuiescentActivePageCacheCapture(pending);
1218
+ return;
1219
+ }
1220
+ const tab = await currentActivePageCacheTab(pending.tabId, pending.key);
1221
+ if (!tab) {
1222
+ return;
1223
+ }
1224
+ const probe = await content.probePageQuiescence(
1225
+ tab.id,
1226
+ ACTIVE_PAGE_CACHE_QUIESCENT_TIMEOUT_MS,
1227
+ ACTIVE_PAGE_CACHE_QUIESCENT_SAMPLE_MS
1228
+ );
1229
+ if (!probe.quiet) {
1230
+ await rescheduleQuiescentActivePageCacheCapture(pending);
1231
+ return;
1232
+ }
1233
+ const captured = await captureActivePageCache(tab, pending.reason, pending.key);
1234
+ if (captured) {
1235
+ activePageCache.lastQuiescentCapturedKey = pending.key;
1236
+ activePageCache.lastQuiescentCapturedAt = Date.now();
1237
+ }
1238
+ }
1239
+ async function captureActivePageCache(tab, reason, key) {
1240
+ if (!state.port) {
1241
+ connectNative();
1242
+ return false;
1243
+ }
1244
+ if (!isEligibleActivePageCacheTab(tab) || activePageCache.inFlightKeys.has(key)) {
1245
+ return false;
1246
+ }
1247
+ activePageCache.inFlightKeys.add(key);
1248
+ try {
1249
+ const captureTab = await currentActivePageCacheTab(tab.id, key);
1250
+ if (!captureTab) {
1251
+ return false;
1252
+ }
1253
+ const extraction = await content.extractPageHtml(captureTab.id, ACTIVE_PAGE_CACHE_TIMEOUT_MS, ACTIVE_PAGE_CACHE_MAX_HTML_CHARS);
1254
+ if (extraction.status !== "READ" || typeof extraction.html !== "string" || extraction.html.length === 0) {
1255
+ void requestPageCacheStatusForTab(captureTab.id, `${reason}:capture-failed`);
1256
+ return false;
1257
+ }
1258
+ const verifiedTab = await currentActivePageCacheTab(captureTab.id, key);
1259
+ if (!verifiedTab) {
1260
+ return false;
1261
+ }
1262
+ const port = state.port;
1263
+ if (!port) {
1264
+ return false;
1265
+ }
1266
+ const id = nextActivePageCacheId();
1267
+ trackPageCacheStatusRequest(id, verifiedTab.id, activePageCacheUrl(verifiedTab));
1268
+ port.postMessage({
1269
+ id,
1270
+ action: "page-cache-capture",
1271
+ ok: true,
1272
+ data: {
1273
+ reason,
1274
+ capturedAt: Date.now(),
1275
+ tab: {
1276
+ tabId: verifiedTab.id,
1277
+ windowId: verifiedTab.windowId,
1278
+ index: verifiedTab.index,
1279
+ url: activePageCacheUrl(verifiedTab),
1280
+ title: verifiedTab.title,
1281
+ groupId: verifiedTab.groupId,
1282
+ favIconUrl: verifiedTab.favIconUrl || null,
1283
+ status: verifiedTab.status || null,
1284
+ pinned: verifiedTab.pinned || false,
1285
+ active: verifiedTab.active || false,
1286
+ incognito: verifiedTab.incognito || false,
1287
+ discarded: verifiedTab.discarded || false,
1288
+ lastAccessedAt: verifiedTab.lastAccessed || null
1289
+ },
1290
+ extraction
1291
+ }
1292
+ });
1293
+ activePageCache.lastCapturedKey = key;
1294
+ return true;
1295
+ } catch (error) {
1296
+ log("active page cache capture failed", { tabId: tab.id, reason, error });
1297
+ void requestPageCacheStatusForTab(tab.id, `${reason}:capture-error`);
1298
+ return false;
1299
+ } finally {
1300
+ activePageCache.inFlightKeys.delete(key);
1301
+ }
1302
+ }
811
1303
  function normalizeEventPayload(kind, payload) {
812
1304
  const event = {
813
1305
  kind,
@@ -924,6 +1416,12 @@
924
1416
  incognito: tab.incognito,
925
1417
  changeInfo
926
1418
  });
1419
+ if ("url" in changeInfo || "status" in changeInfo || "discarded" in changeInfo) {
1420
+ void clearCacheAvailableIndicator(tabId);
1421
+ }
1422
+ if (tab.active && ("url" in changeInfo || "status" in changeInfo || "discarded" in changeInfo)) {
1423
+ scheduleActivePageCacheCapture(tab, "tabs.onUpdated");
1424
+ }
927
1425
  });
928
1426
  chrome.tabs?.onMoved?.addListener((tabId, moveInfo) => {
929
1427
  enqueueBrowserStateEvent("tabs.onMoved", {
@@ -953,12 +1451,19 @@
953
1451
  windowId: removeInfo.windowId,
954
1452
  isWindowClosing: removeInfo.isWindowClosing
955
1453
  });
1454
+ activePageCache.statusRequests.forEach((pending, requestId) => {
1455
+ if (pending.tabId === tabId) {
1456
+ activePageCache.statusRequests.delete(requestId);
1457
+ }
1458
+ });
1459
+ void clearCacheAvailableIndicator(tabId);
956
1460
  });
957
1461
  chrome.tabs?.onActivated?.addListener((activeInfo) => {
958
1462
  enqueueBrowserStateEvent("tabs.onActivated", {
959
1463
  tabId: activeInfo.tabId,
960
1464
  windowId: activeInfo.windowId
961
1465
  });
1466
+ void scheduleActivePageCacheCaptureForTab(activeInfo.tabId, "tabs.onActivated");
962
1467
  });
963
1468
  chrome.tabGroups?.onCreated?.addListener((group) => {
964
1469
  enqueueBrowserStateEvent("tabGroups.onCreated", {
@@ -1009,6 +1514,7 @@
1009
1514
  });
1010
1515
  chrome.windows?.onFocusChanged?.addListener((windowId) => {
1011
1516
  enqueueBrowserStateEvent("windows.onFocusChanged", { windowId });
1517
+ void scheduleActivePageCacheCaptureForWindow(windowId, "windows.onFocusChanged");
1012
1518
  });
1013
1519
  }
1014
1520
  connectNative();
@@ -1141,9 +1647,17 @@
1141
1647
  }
1142
1648
  case "p:page-html": {
1143
1649
  const targetTabId = requireFiniteId(params.tabId, "tabId");
1144
- const maxHtmlChars = typeof params.maxHtmlChars === "number" ? Math.max(1, Math.min(params.maxHtmlChars, 65e4)) : 5e5;
1650
+ const expectedUrl = typeof params.expectedUrl === "string" ? params.expectedUrl : "";
1651
+ const maxHtmlChars = typeof params.maxHtmlChars === "number" ? Math.max(1, Math.min(params.maxHtmlChars, MAX_PAGE_HTML_CHARS)) : 5e5;
1145
1652
  const timeoutMs = typeof params.timeoutMs === "number" ? Math.max(1, params.timeoutMs) : 15e3;
1146
- return await content.extractPageHtml(targetTabId, timeoutMs, maxHtmlChars);
1653
+ if (expectedUrl && !await tabUrlMatches(targetTabId, expectedUrl)) {
1654
+ return urlMismatchPageHtmlResponse(expectedUrl);
1655
+ }
1656
+ const result = await content.extractPageHtml(targetTabId, timeoutMs, maxHtmlChars);
1657
+ if (expectedUrl && !await tabUrlMatches(targetTabId, expectedUrl)) {
1658
+ return urlMismatchPageHtmlResponse(expectedUrl);
1659
+ }
1660
+ return result;
1147
1661
  }
1148
1662
  default:
1149
1663
  throw new Error(`Unknown action: ${action}`);
@@ -7,6 +7,7 @@ exports.executeWithTimeoutDetailed = executeWithTimeoutDetailed;
7
7
  exports.extractPageMeta = extractPageMeta;
8
8
  exports.extractPageMarkdown = extractPageMarkdown;
9
9
  exports.extractPageHtml = extractPageHtml;
10
+ exports.probePageQuiescence = probePageQuiescence;
10
11
  exports.extractSelectorSignal = extractSelectorSignal;
11
12
  function delay(ms) {
12
13
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -153,6 +154,120 @@ async function extractPageHtml(tabId, timeoutMs, maxHtmlChars) {
153
154
  error: null,
154
155
  };
155
156
  }
157
+ async function probePageQuiescence(tabId, timeoutMs, sampleWindowMs) {
158
+ const result = await executeWithTimeoutDetailed(tabId, timeoutMs, async (rawWindowMs) => {
159
+ const startedAt = Date.now();
160
+ const windowMs = Math.max(50, Math.min(Number(rawWindowMs) || 350, 1_500));
161
+ const htmlCap = 250_000;
162
+ const sample = () => {
163
+ const root = document.documentElement;
164
+ const bodyText = document.body?.innerText || root?.textContent || "";
165
+ const html = root?.outerHTML || "";
166
+ const resources = typeof performance !== "undefined" && typeof performance.getEntriesByType === "function"
167
+ ? performance.getEntriesByType("resource").length
168
+ : null;
169
+ return {
170
+ textChars: bodyText.length,
171
+ htmlChars: Math.min(html.length, htmlCap),
172
+ domElements: document.getElementsByTagName("*").length,
173
+ resourceCount: resources,
174
+ };
175
+ };
176
+ const waitForIdleWindow = () => new Promise((resolve) => {
177
+ const win = window;
178
+ const done = () => setTimeout(resolve, windowMs);
179
+ if (typeof win.requestIdleCallback === "function") {
180
+ let resolved = false;
181
+ const finish = () => {
182
+ if (resolved) {
183
+ return;
184
+ }
185
+ resolved = true;
186
+ done();
187
+ };
188
+ const fallback = setTimeout(finish, windowMs + 100);
189
+ win.requestIdleCallback(() => {
190
+ clearTimeout(fallback);
191
+ finish();
192
+ }, { timeout: windowMs });
193
+ return;
194
+ }
195
+ setTimeout(resolve, windowMs);
196
+ });
197
+ try {
198
+ const readyState = document.readyState;
199
+ if (readyState !== "interactive" && readyState !== "complete") {
200
+ return {
201
+ quiet: false,
202
+ reason: "not-ready",
203
+ documentReadyState: readyState,
204
+ before: null,
205
+ after: null,
206
+ elapsedMs: Date.now() - startedAt,
207
+ error: null,
208
+ };
209
+ }
210
+ const before = sample();
211
+ await waitForIdleWindow();
212
+ const after = sample();
213
+ const stable = before.textChars === after.textChars
214
+ && before.htmlChars === after.htmlChars
215
+ && before.domElements === after.domElements
216
+ && before.resourceCount === after.resourceCount;
217
+ return {
218
+ quiet: stable,
219
+ reason: stable ? "stable" : "changed",
220
+ documentReadyState: document.readyState,
221
+ before,
222
+ after,
223
+ elapsedMs: Date.now() - startedAt,
224
+ error: null,
225
+ };
226
+ }
227
+ catch (err) {
228
+ return {
229
+ quiet: false,
230
+ reason: "probe-error",
231
+ documentReadyState: typeof document !== "undefined" ? document.readyState : null,
232
+ before: null,
233
+ after: null,
234
+ elapsedMs: Date.now() - startedAt,
235
+ error: err instanceof Error ? err.message : String(err),
236
+ };
237
+ }
238
+ }, [sampleWindowMs]);
239
+ if (result.kind === "timeout") {
240
+ return {
241
+ quiet: false,
242
+ reason: "timed-out",
243
+ documentReadyState: null,
244
+ before: null,
245
+ after: null,
246
+ elapsedMs: timeoutMs,
247
+ error: `Timed out after ${timeoutMs}ms`,
248
+ };
249
+ }
250
+ if (result.kind === "error") {
251
+ return {
252
+ quiet: false,
253
+ reason: "injection-error",
254
+ documentReadyState: null,
255
+ before: null,
256
+ after: null,
257
+ elapsedMs: 0,
258
+ error: result.message,
259
+ };
260
+ }
261
+ return result.value ?? {
262
+ quiet: false,
263
+ reason: "no-result",
264
+ documentReadyState: null,
265
+ before: null,
266
+ after: null,
267
+ elapsedMs: 0,
268
+ error: "Content script returned no quiescence probe payload",
269
+ };
270
+ }
156
271
  async function extractSelectorSignal(tabId, specs, timeoutMs, selectorValueMaxLength) {
157
272
  if (!specs.length) {
158
273
  return null;
@@ -19,5 +19,8 @@
19
19
  "background": {
20
20
  "service_worker": "background.js"
21
21
  },
22
- "version_name": "0.6.0-rc.7"
22
+ "action": {
23
+ "default_title": "Tab Control"
24
+ },
25
+ "version_name": "0.6.0-rc.8"
23
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabctl",
3
- "version": "0.6.0-rc.7",
3
+ "version": "0.6.0-rc.8",
4
4
  "description": "CLI tool to manage and analyze browser tabs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "typescript": "^5.4.5"
54
54
  },
55
55
  "optionalDependencies": {
56
- "tabctl-win32-x64": "0.6.0-rc.7"
56
+ "tabctl-win32-x64": "0.6.0-rc.8"
57
57
  },
58
58
  "dependencies": {
59
59
  "normalize-url": "^8.1.1"