harnery 0.45.2 → 0.46.0

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.
Files changed (216) hide show
  1. package/dist/commander.d.ts +13 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commands/admission.d.ts.map +1 -1
  4. package/dist/commands/admission.js +214 -27
  5. package/dist/commands/agents.d.ts +4 -0
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +102 -25
  8. package/dist/commands/browse.d.ts +3 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +172 -8
  11. package/dist/commands/fetch.js +1 -0
  12. package/dist/commands/ledger-v3.d.ts.map +1 -1
  13. package/dist/commands/ledger-v3.js +8 -2
  14. package/dist/commands/presence.d.ts.map +1 -1
  15. package/dist/commands/presence.js +40 -0
  16. package/dist/commands/qa-run.d.ts.map +1 -1
  17. package/dist/commands/qa-run.js +4 -3
  18. package/dist/commands/review-pack.d.ts.map +1 -1
  19. package/dist/commands/review-pack.js +33 -6
  20. package/dist/core/agents/index.d.ts +1 -0
  21. package/dist/core/agents/index.d.ts.map +1 -1
  22. package/dist/core/agents/index.js +1 -0
  23. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  24. package/dist/core/agents/live-authority-v3.js +11 -4
  25. package/dist/core/agents/render/session-context.d.ts +0 -6
  26. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  27. package/dist/core/agents/render/session-context.js +1 -6
  28. package/dist/core/agents/session-events.d.ts.map +1 -1
  29. package/dist/core/agents/session-events.js +12 -4
  30. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  31. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  32. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  33. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  34. package/dist/core/agents/state/live-coordination-view.js +6 -2
  35. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  36. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  37. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  38. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  39. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  40. package/dist/core/agents/state/parent-reference.js +119 -0
  41. package/dist/core/config.d.ts +39 -0
  42. package/dist/core/config.d.ts.map +1 -1
  43. package/dist/core/config.js +63 -0
  44. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  45. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  46. package/dist/core/diagnostics/path-identity.js +51 -6
  47. package/dist/core/events/v3/canonical.js +43 -14
  48. package/dist/core/events/v3/index.d.ts +1 -1
  49. package/dist/core/events/v3/index.d.ts.map +1 -1
  50. package/dist/core/events/v3/index.js +1 -1
  51. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  52. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  53. package/dist/core/events/v3/producers/recorder.js +117 -13
  54. package/dist/core/events/v3/reader.d.ts.map +1 -1
  55. package/dist/core/events/v3/reader.js +53 -13
  56. package/dist/core/events/v3/validate.d.ts.map +1 -1
  57. package/dist/core/events/v3/validate.js +36 -1
  58. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  59. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  60. package/dist/core/hooks/adapter/native-parent.js +91 -0
  61. package/dist/core/hooks/cli.js +79 -4
  62. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  63. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  64. package/dist/core/hooks/resolve/owner.js +0 -23
  65. package/dist/core/hooks/shell-admission.d.ts +84 -0
  66. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  67. package/dist/core/hooks/shell-admission.js +622 -0
  68. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  69. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  70. package/dist/core/hooks/shell-commit-tree.js +104 -0
  71. package/dist/core/inbox/contract.d.ts +12 -0
  72. package/dist/core/inbox/contract.d.ts.map +1 -1
  73. package/dist/core/inbox/contract.js +16 -0
  74. package/dist/core/inbox/peer-messages.d.ts +41 -16
  75. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  76. package/dist/core/inbox/peer-messages.js +372 -15
  77. package/dist/core/inbox/service.d.ts +8 -1
  78. package/dist/core/inbox/service.d.ts.map +1 -1
  79. package/dist/core/inbox/service.js +83 -3
  80. package/dist/core/inbox/targets.d.ts +17 -3
  81. package/dist/core/inbox/targets.d.ts.map +1 -1
  82. package/dist/core/inbox/targets.js +77 -2
  83. package/dist/core/presence/git.d.ts +18 -12
  84. package/dist/core/presence/git.d.ts.map +1 -1
  85. package/dist/core/presence/git.js +32 -38
  86. package/dist/core/presence/index.d.ts +2 -2
  87. package/dist/core/presence/index.d.ts.map +1 -1
  88. package/dist/core/presence/index.js +8 -6
  89. package/dist/core/presence/mail.d.ts +133 -0
  90. package/dist/core/presence/mail.d.ts.map +1 -0
  91. package/dist/core/presence/mail.js +464 -0
  92. package/dist/core/semantic/contract.d.ts +5 -5
  93. package/dist/core/storage/modes.d.ts +4 -0
  94. package/dist/core/storage/modes.d.ts.map +1 -1
  95. package/dist/core/storage/modes.js +4 -0
  96. package/dist/core/storage/segments.d.ts.map +1 -1
  97. package/dist/core/storage/segments.js +5 -4
  98. package/dist/lib/admission-history.d.ts +96 -0
  99. package/dist/lib/admission-history.d.ts.map +1 -0
  100. package/dist/lib/admission-history.js +196 -0
  101. package/dist/lib/admission.d.ts +56 -7
  102. package/dist/lib/admission.d.ts.map +1 -1
  103. package/dist/lib/admission.js +161 -19
  104. package/dist/lib/browser/client.d.ts +18 -0
  105. package/dist/lib/browser/client.d.ts.map +1 -1
  106. package/dist/lib/browser/client.js +100 -12
  107. package/dist/lib/browser/content-checks.d.ts +3 -1
  108. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  109. package/dist/lib/browser/content-checks.js +10 -2
  110. package/dist/lib/browser/critique.d.ts +32 -0
  111. package/dist/lib/browser/critique.d.ts.map +1 -1
  112. package/dist/lib/browser/critique.js +72 -3
  113. package/dist/lib/browser/geometry.d.ts +2 -0
  114. package/dist/lib/browser/geometry.d.ts.map +1 -1
  115. package/dist/lib/browser/geometry.js +61 -15
  116. package/dist/lib/browser/index.d.ts +1 -1
  117. package/dist/lib/browser/index.d.ts.map +1 -1
  118. package/dist/lib/browser/index.js +1 -1
  119. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  120. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  121. package/dist/lib/browser/page-review-contracts.js +6 -1
  122. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  123. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  124. package/dist/lib/browser/page-review-judge.js +41 -11
  125. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  126. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  127. package/dist/lib/browser/page-review-pack.js +5 -2
  128. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  129. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  130. package/dist/lib/browser/qa-run-contracts.js +11 -4
  131. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  132. package/dist/lib/browser/qa-run.js +73 -16
  133. package/dist/lib/browser/runts.d.ts +4 -2
  134. package/dist/lib/browser/runts.d.ts.map +1 -1
  135. package/dist/lib/browser/runts.js +84 -33
  136. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  137. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  138. package/dist/lib/browser/windows-chrome.js +42 -0
  139. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  140. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  141. package/dist/lib/browser/windows-login-sync.js +171 -0
  142. package/dist/lib/cookies/client.d.ts +30 -5
  143. package/dist/lib/cookies/client.d.ts.map +1 -1
  144. package/dist/lib/cookies/client.js +55 -10
  145. package/dist/lib/cookies/index.d.ts +1 -1
  146. package/dist/lib/cookies/index.d.ts.map +1 -1
  147. package/dist/lib/cookies/index.js +1 -1
  148. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  149. package/dist/lib/docs-metadata-sync.js +41 -2
  150. package/dist/lib/durable-job.d.ts +8 -0
  151. package/dist/lib/durable-job.d.ts.map +1 -1
  152. package/dist/lib/durable-job.js +10 -0
  153. package/dist/lib/http/client.d.ts +9 -8
  154. package/dist/lib/http/client.d.ts.map +1 -1
  155. package/dist/lib/http/client.js +44 -2
  156. package/dist/lib/instructions/templates.js +4 -4
  157. package/package.json +2 -2
  158. package/src/commander.ts +13 -0
  159. package/src/commands/admission.ts +272 -29
  160. package/src/commands/agents.ts +132 -27
  161. package/src/commands/browse.ts +208 -6
  162. package/src/commands/fetch.ts +1 -0
  163. package/src/commands/ledger-v3.ts +8 -2
  164. package/src/commands/presence.ts +49 -0
  165. package/src/commands/qa-run.ts +4 -3
  166. package/src/commands/review-pack.ts +41 -5
  167. package/src/core/agents/index.ts +6 -0
  168. package/src/core/agents/live-authority-v3.ts +11 -4
  169. package/src/core/agents/render/session-context.ts +1 -7
  170. package/src/core/agents/session-events.ts +18 -3
  171. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  172. package/src/core/agents/state/live-coordination-view.ts +13 -6
  173. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  174. package/src/core/agents/state/parent-reference.ts +179 -0
  175. package/src/core/config.ts +85 -0
  176. package/src/core/diagnostics/path-identity.ts +52 -6
  177. package/src/core/events/v3/canonical.ts +46 -13
  178. package/src/core/events/v3/index.ts +3 -0
  179. package/src/core/events/v3/producers/recorder.ts +151 -13
  180. package/src/core/events/v3/reader.ts +55 -11
  181. package/src/core/events/v3/validate.ts +42 -1
  182. package/src/core/hooks/adapter/native-parent.ts +109 -0
  183. package/src/core/hooks/cli.ts +83 -4
  184. package/src/core/hooks/resolve/owner.ts +0 -23
  185. package/src/core/hooks/shell-admission.ts +634 -0
  186. package/src/core/hooks/shell-commit-tree.ts +102 -0
  187. package/src/core/inbox/contract.ts +18 -0
  188. package/src/core/inbox/peer-messages.ts +501 -15
  189. package/src/core/inbox/service.ts +108 -3
  190. package/src/core/inbox/targets.ts +94 -3
  191. package/src/core/presence/git.ts +45 -37
  192. package/src/core/presence/index.ts +16 -8
  193. package/src/core/presence/mail.ts +620 -0
  194. package/src/core/storage/modes.ts +6 -0
  195. package/src/core/storage/segments.ts +5 -4
  196. package/src/lib/admission-history.ts +267 -0
  197. package/src/lib/admission.ts +217 -19
  198. package/src/lib/browser/client.ts +147 -35
  199. package/src/lib/browser/content-checks.ts +14 -3
  200. package/src/lib/browser/critique.ts +113 -3
  201. package/src/lib/browser/geometry.ts +63 -14
  202. package/src/lib/browser/index.ts +5 -0
  203. package/src/lib/browser/page-review-contracts.ts +6 -1
  204. package/src/lib/browser/page-review-judge.ts +58 -13
  205. package/src/lib/browser/page-review-pack.ts +13 -2
  206. package/src/lib/browser/qa-run-contracts.ts +30 -4
  207. package/src/lib/browser/qa-run.ts +78 -17
  208. package/src/lib/browser/runts.ts +85 -37
  209. package/src/lib/browser/windows-chrome.ts +50 -0
  210. package/src/lib/browser/windows-login-sync.ts +275 -0
  211. package/src/lib/cookies/client.ts +62 -10
  212. package/src/lib/cookies/index.ts +3 -0
  213. package/src/lib/docs-metadata-sync.ts +44 -2
  214. package/src/lib/durable-job.ts +15 -0
  215. package/src/lib/http/client.ts +63 -1
  216. package/src/lib/instructions/templates.ts +4 -4
@@ -15,6 +15,7 @@ import {
15
15
  type CookieJar,
16
16
  CookieStoreParseError,
17
17
  type Cookie as JarCookie,
18
+ violatesCookiePrefix,
18
19
  } from "../cookies/index.ts";
19
20
  import { PaceGate, pacePolicyFromEnv } from "../pace/index.ts";
20
21
  import { type AssertResult, type AssertSpec, buildAssertCheck } from "./asserts.js";
@@ -170,8 +171,22 @@ export interface BrowserOptions {
170
171
  userAgent?: string;
171
172
  /** Authenticated browser proxy passed directly to Playwright. */
172
173
  proxy?: { server: string; username?: string; password?: string };
174
+ /**
175
+ * Inspect the live cookies of the browser context. Runs before each request
176
+ * is sent, after each response, and once more at close. If it throws, the
177
+ * request is cancelled, the context closes without saving cookies to the
178
+ * jar, and the error is kept as `cookieGuardFault` for the caller to report.
179
+ */
180
+ cookieGuard?: CookieGuard;
173
181
  }
174
182
 
183
+ export type CookieGuardPhase = "request" | "response" | "close";
184
+
185
+ export type CookieGuard = (
186
+ cookies: readonly PWCookie[],
187
+ event: { phase: CookieGuardPhase; url: string | null },
188
+ ) => void | Promise<void>;
189
+
175
190
  export interface NavigateResult {
176
191
  url: string;
177
192
  title: string;
@@ -422,16 +437,35 @@ export class Browser {
422
437
  : undefined;
423
438
  if (identity) await this.context.addInitScript(installDocumentIdentity, identity);
424
439
 
425
- if (jarCookies.length > 0) {
426
- await this.context.addCookies(jarCookies.map(toPWCookie));
440
+ // The jar's domain field is the delivery rule: a dotless domain is
441
+ // host-only, which Chromium honors for exactly that host. A cookie that
442
+ // breaks its name prefix would make Chromium reject the whole batch, so
443
+ // it is left out rather than aborting the session.
444
+ const injectable = jarCookies.filter((cookie) => !violatesCookiePrefix(cookie));
445
+ if (injectable.length > 0) {
446
+ await this.context.addCookies(injectable.map(toPWCookie));
427
447
  }
428
448
 
429
449
  // Gate actual document requests, including links, forms, script navigation,
430
450
  // and the first request of a popup. Method-level waits miss those loads.
431
451
  // Redirect hops are part of the original load, not additional reservations.
432
452
  const headersCb = this.opts.extraHeaders;
433
- if (this.getPaceGate()?.policy.enabled || headersCb || this.opts.userAgent) {
453
+ const cookieGuard = this.opts.cookieGuard;
454
+ if (cookieGuard) {
455
+ this.context.on("response", (response) => {
456
+ void this.guardCookies("response", response.url()).catch(() => undefined);
457
+ });
458
+ }
459
+ if (this.getPaceGate()?.policy.enabled || headersCb || this.opts.userAgent || cookieGuard) {
434
460
  await this.context.route("**/*", async (route, request) => {
461
+ if (cookieGuard) {
462
+ try {
463
+ await this.guardCookies("request", request.url());
464
+ } catch {
465
+ await route.abort("failed").catch(() => undefined);
466
+ return;
467
+ }
468
+ }
435
469
  let mainDocument = false;
436
470
  if (request.isNavigationRequest() && !request.redirectedFrom()) {
437
471
  try {
@@ -848,24 +882,31 @@ export class Browser {
848
882
  `captureRegionByScroll: rect must have a positive size (got ${rect.width}×${rect.height})`,
849
883
  );
850
884
  }
851
- const settle = (top: number) =>
852
- page.evaluate(async (target: number) => {
853
- window.scrollTo({ left: window.scrollX, top: target, behavior: "instant" });
854
- const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
855
- let last = Number.NaN;
856
- for (let i = 0; i < 8; i++) {
857
- await frame();
858
- const now = window.scrollY;
859
- if (now === last) break;
860
- last = now;
861
- }
862
- return {
863
- scrollX: window.scrollX,
864
- scrollY: window.scrollY,
865
- innerWidth: window.innerWidth,
866
- innerHeight: window.innerHeight,
867
- };
868
- }, top);
885
+ const settle = (top: number, left?: number) =>
886
+ page.evaluate(
887
+ async (target: { top: number; left?: number }) => {
888
+ window.scrollTo({
889
+ left: target.left ?? window.scrollX,
890
+ top: target.top,
891
+ behavior: "instant",
892
+ });
893
+ const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
894
+ let last = Number.NaN;
895
+ for (let i = 0; i < 8; i++) {
896
+ await frame();
897
+ const now = window.scrollY;
898
+ if (now === last) break;
899
+ last = now;
900
+ }
901
+ return {
902
+ scrollX: window.scrollX,
903
+ scrollY: window.scrollY,
904
+ innerWidth: window.innerWidth,
905
+ innerHeight: window.innerHeight,
906
+ };
907
+ },
908
+ { top, left },
909
+ );
869
910
  const original = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
870
911
  const captures: Buffer[] = [];
871
912
  // Viewport-pinned candidates, measured at scroll 0 so each later capture
@@ -884,6 +925,8 @@ export class Browser {
884
925
  priorOpacityPriority: string;
885
926
  priorTransition: string;
886
927
  priorTransitionPriority: string;
928
+ priorStyle: string | null;
929
+ appliedStyle?: string;
887
930
  }> = [];
888
931
  // Fixed controls can live inside a static shadow host. Walk open roots
889
932
  // without hiding their ordinary content or exceeding the total node cap.
@@ -907,6 +950,7 @@ export class Browser {
907
950
  priorOpacityPriority: el.style.getPropertyPriority("opacity"),
908
951
  priorTransition: el.style.getPropertyValue("transition"),
909
952
  priorTransitionPriority: el.style.getPropertyPriority("transition"),
953
+ priorStyle: el.getAttribute("style"),
910
954
  });
911
955
  }
912
956
  return out;
@@ -916,16 +960,36 @@ export class Browser {
916
960
  async ([entries, action]) => {
917
961
  for (const entry of entries) {
918
962
  const restore = () => {
963
+ if (entry.appliedStyle === undefined) return;
964
+ const unchanged = entry.el.getAttribute("style") === entry.appliedStyle;
919
965
  // Restore opacity while transitions are still disabled. Restoring both
920
966
  // declarations in one style flush starts a new fade from zero.
921
- entry.el.style.setProperty("transition", "none", "important");
922
- entry.el.style.setProperty("opacity", entry.priorOpacity, entry.priorOpacityPriority);
967
+ if (
968
+ entry.el.style.getPropertyValue("opacity") === "0" &&
969
+ entry.el.style.getPropertyPriority("opacity") === "important"
970
+ )
971
+ entry.el.style.setProperty(
972
+ "opacity",
973
+ entry.priorOpacity,
974
+ entry.priorOpacityPriority,
975
+ );
923
976
  void getComputedStyle(entry.el).opacity;
924
- entry.el.style.setProperty(
925
- "transition",
926
- entry.priorTransition,
927
- entry.priorTransitionPriority,
928
- );
977
+ if (
978
+ entry.el.style.getPropertyValue("transition") === "none" &&
979
+ entry.el.style.getPropertyPriority("transition") === "important"
980
+ )
981
+ entry.el.style.setProperty(
982
+ "transition",
983
+ entry.priorTransition,
984
+ entry.priorTransitionPriority,
985
+ );
986
+ // Preserve the exact original attribute, including its absence. Do
987
+ // not replace styles changed by the page while our override was active.
988
+ if (unchanged) {
989
+ if (entry.priorStyle === null) entry.el.removeAttribute("style");
990
+ else entry.el.setAttribute("style", entry.priorStyle);
991
+ }
992
+ entry.appliedStyle = undefined;
929
993
  };
930
994
  if (action === "restore") {
931
995
  restore();
@@ -933,8 +997,21 @@ export class Browser {
933
997
  }
934
998
  const now = entry.el.getBoundingClientRect().top + window.scrollY;
935
999
  if (Math.abs(now - entry.top) > 1) {
1000
+ if (entry.appliedStyle !== undefined) {
1001
+ if (entry.el.getAttribute("style") !== entry.appliedStyle)
1002
+ throw new Error(
1003
+ "Pinned styles changed during native capture; source is unstable.",
1004
+ );
1005
+ continue;
1006
+ }
1007
+ entry.priorStyle = entry.el.getAttribute("style");
1008
+ entry.priorOpacity = entry.el.style.getPropertyValue("opacity");
1009
+ entry.priorOpacityPriority = entry.el.style.getPropertyPriority("opacity");
1010
+ entry.priorTransition = entry.el.style.getPropertyValue("transition");
1011
+ entry.priorTransitionPriority = entry.el.style.getPropertyPriority("transition");
936
1012
  entry.el.style.setProperty("transition", "none", "important");
937
1013
  entry.el.style.setProperty("opacity", "0", "important");
1014
+ entry.appliedStyle = entry.el.getAttribute("style")!;
938
1015
  } else {
939
1016
  restore();
940
1017
  }
@@ -990,11 +1067,8 @@ export class Browser {
990
1067
  await syncPinned("restore");
991
1068
  } finally {
992
1069
  await pinned.dispose();
993
- await page.evaluate(
994
- ({ x, y }: { x: number; y: number }) =>
995
- window.scrollTo({ left: x, top: y, behavior: "instant" }),
996
- original,
997
- );
1070
+ await settle(original.y, original.x);
1071
+ await this.waitForReviewReady();
998
1072
  }
999
1073
  }
1000
1074
  return captures;
@@ -1863,10 +1937,17 @@ export class Browser {
1863
1937
  * visual line. Word-count per line via per-word Range rects (the width
1864
1938
  * of the last line is deliberately NOT the signal; see runts.ts).
1865
1939
  */
1866
- async checkRunts(opts: { scope?: string | null; minChars?: number } = {}): Promise<RuntsResult> {
1940
+ async checkRunts(
1941
+ opts: { scope?: string | null; minChars?: number; maxFindings?: number } = {},
1942
+ ): Promise<RuntsResult> {
1943
+ const maxFindings = opts.maxFindings === undefined ? 50 : opts.maxFindings;
1944
+ if (!Number.isInteger(maxFindings) || maxFindings < 1 || maxFindings > 5000) {
1945
+ throw new Error("maxFindings must be an integer from 1 to 5000.");
1946
+ }
1867
1947
  return await this.currentPage.evaluate(buildRuntsCheck(), {
1868
1948
  scope: opts.scope ?? null,
1869
1949
  minChars: opts.minChars ?? 40,
1950
+ maxFindings,
1870
1951
  });
1871
1952
  }
1872
1953
 
@@ -1973,6 +2054,30 @@ export class Browser {
1973
2054
  await this.context.addInitScript(script);
1974
2055
  }
1975
2056
 
2057
+ /** The error a `cookieGuard` threw, once it has stopped the session. */
2058
+ get cookieGuardFault(): unknown {
2059
+ return this.cookieFault;
2060
+ }
2061
+
2062
+ private cookieFault: unknown;
2063
+
2064
+ /** Run the cookie guard; on a throw, record it, tear the context down, and rethrow. */
2065
+ private async guardCookies(phase: CookieGuardPhase, url: string | null): Promise<void> {
2066
+ const guard = this.opts.cookieGuard;
2067
+ const context = this.context;
2068
+ if (!guard || !context || this.cookieFault) {
2069
+ if (this.cookieFault) throw this.cookieFault;
2070
+ return;
2071
+ }
2072
+ try {
2073
+ await guard(await context.cookies(), { phase, url });
2074
+ } catch (error) {
2075
+ this.cookieFault ??= error;
2076
+ await this.close();
2077
+ throw error;
2078
+ }
2079
+ }
2080
+
1976
2081
  /** Snapshot all cookies in the live persistent context. */
1977
2082
  async cookies(): Promise<PWCookie[]> {
1978
2083
  if (!this.context) throw new Error("Browser not opened. Call open() first.");
@@ -1985,7 +2090,14 @@ export class Browser {
1985
2090
  */
1986
2091
  async close(): Promise<void> {
1987
2092
  if (!this.context) return;
1988
- if (this.opts.jar) {
2093
+ if (this.opts.cookieGuard && !this.cookieFault) {
2094
+ try {
2095
+ await this.guardCookies("close", null);
2096
+ } catch {
2097
+ return;
2098
+ }
2099
+ }
2100
+ if (this.opts.jar && !this.cookieFault) {
1989
2101
  try {
1990
2102
  const live = await this.context.cookies();
1991
2103
  this.opts.jar.merge(live.map(toJarCookie));
@@ -36,7 +36,7 @@ export interface PlaceholderHit {
36
36
  export interface PlaceholderResult {
37
37
  rule: "placeholder";
38
38
  found: boolean;
39
- outcome: "pass" | "fail";
39
+ outcome: "pass" | "fail" | "unknown";
40
40
  scanned: number;
41
41
  truncated: boolean;
42
42
  hits: PlaceholderHit[];
@@ -100,6 +100,8 @@ export interface ContrastResult {
100
100
  }
101
101
 
102
102
  export interface ContentChecksRequest {
103
+ /** Explicit traversal budget; omission retains the existing node cap. */
104
+ scanNodeBudget?: number;
103
105
  placeholder: { scope: string | null } | null;
104
106
  image: { scope: string | null; tolerance: number } | null;
105
107
  truncation: { scope: string | null; tolerance: number } | null;
@@ -115,7 +117,15 @@ export interface ContentChecksResult {
115
117
 
116
118
  export function buildContentChecks(): (request: ContentChecksRequest) => ContentChecksResult {
117
119
  return (request) => {
118
- const NODE_CAP = 6000;
120
+ if (
121
+ request.scanNodeBudget !== undefined &&
122
+ (!Number.isSafeInteger(request.scanNodeBudget) ||
123
+ request.scanNodeBudget < 1 ||
124
+ request.scanNodeBudget > 100_000)
125
+ ) {
126
+ throw new Error("scanNodeBudget must be an integer from 1 to 100000.");
127
+ }
128
+ const NODE_CAP = request.scanNodeBudget ?? 6000;
119
129
  const HIT_CAP = 100;
120
130
 
121
131
  const sx = window.scrollX;
@@ -234,6 +244,7 @@ export function buildContentChecks(): (request: ContentChecksRequest) => Content
234
244
  // when the trimmed text of a leaf element is exactly the tell).
235
245
  if (hits.length < HIT_CAP) {
236
246
  const leaves = root.querySelectorAll("*");
247
+ if (leaves.length > NODE_CAP) truncated = true;
237
248
  for (let i = 0; i < leaves.length && i < NODE_CAP; i++) {
238
249
  const el = leaves[i];
239
250
  if (!el || el.children.length > 0) continue;
@@ -258,7 +269,7 @@ export function buildContentChecks(): (request: ContentChecksRequest) => Content
258
269
  placeholder = {
259
270
  rule: "placeholder",
260
271
  found: root !== null,
261
- outcome: root && hits.length === 0 ? "pass" : "fail",
272
+ outcome: !root || hits.length > 0 ? "fail" : truncated ? "unknown" : "pass",
262
273
  scanned,
263
274
  truncated,
264
275
  hits,
@@ -18,6 +18,7 @@
18
18
  // orchestration live here; the model call lives in the host.
19
19
 
20
20
  import { PNG } from "pngjs";
21
+ import { PAGE_REVIEW_MAX_TILE_BUDGET } from "./page-review-contracts.js";
21
22
  import {
22
23
  bandOversizedRect,
23
24
  bandsCoveringRects,
@@ -76,6 +77,87 @@ export interface CritiqueResult {
76
77
  error?: string;
77
78
  /** Host-reported provider provenance (route, attempts, fallbacks, usage). */
78
79
  provider_meta?: Record<string, unknown>;
80
+ /** Tokens the provider reported across every tile it was called for. */
81
+ usage?: CritiqueUsageTotals;
82
+ }
83
+
84
+ /** Tokens one provider call spent, as the host's backend reported them.
85
+ * `input_tokens` counts every input token, cached or not. */
86
+ export interface CritiqueUsage {
87
+ input_tokens: number;
88
+ output_tokens: number;
89
+ cache_read_input_tokens?: number;
90
+ cache_write_input_tokens?: number;
91
+ /** Cost at the backend's list price, when the backend reports one. */
92
+ cost_usd?: number;
93
+ }
94
+
95
+ /** Usage summed over tiles. A tile whose provider reported nothing counts in
96
+ * `tiles_unreported`, never as zero tokens. */
97
+ export interface CritiqueUsageTotals {
98
+ tiles_reported: number;
99
+ tiles_unreported: number;
100
+ input_tokens: number;
101
+ output_tokens: number;
102
+ cache_read_input_tokens: number;
103
+ cache_write_input_tokens: number;
104
+ /** Present only when every reported tile carried a cost. */
105
+ cost_usd?: number;
106
+ }
107
+
108
+ /** Add one call's usage to a tile's running usage (a retried tile reports twice). */
109
+ export function addCritiqueUsage(
110
+ into: CritiqueUsage | undefined,
111
+ usage: CritiqueUsage,
112
+ ): CritiqueUsage {
113
+ const n = (v: number | undefined) => (Number.isFinite(v) ? (v as number) : 0);
114
+ const out: CritiqueUsage = {
115
+ input_tokens: n(into?.input_tokens) + n(usage.input_tokens),
116
+ output_tokens: n(into?.output_tokens) + n(usage.output_tokens),
117
+ };
118
+ const read = n(into?.cache_read_input_tokens) + n(usage.cache_read_input_tokens);
119
+ const write = n(into?.cache_write_input_tokens) + n(usage.cache_write_input_tokens);
120
+ if (read > 0) out.cache_read_input_tokens = read;
121
+ if (write > 0) out.cache_write_input_tokens = write;
122
+ const costKnown =
123
+ usage.cost_usd !== undefined && (into === undefined || into.cost_usd !== undefined);
124
+ if (costKnown) out.cost_usd = n(into?.cost_usd) + n(usage.cost_usd);
125
+ return out;
126
+ }
127
+
128
+ export function sumCritiqueUsage(
129
+ perTile: ReadonlyArray<CritiqueUsage | undefined>,
130
+ ): CritiqueUsageTotals {
131
+ const reported = perTile.filter((u): u is CritiqueUsage => u !== undefined);
132
+ const totals: CritiqueUsageTotals = {
133
+ tiles_reported: reported.length,
134
+ tiles_unreported: perTile.length - reported.length,
135
+ input_tokens: 0,
136
+ output_tokens: 0,
137
+ cache_read_input_tokens: 0,
138
+ cache_write_input_tokens: 0,
139
+ };
140
+ for (const u of reported) {
141
+ totals.input_tokens += u.input_tokens;
142
+ totals.output_tokens += u.output_tokens;
143
+ totals.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
144
+ totals.cache_write_input_tokens += u.cache_write_input_tokens ?? 0;
145
+ }
146
+ if (reported.length > 0 && reported.every((u) => u.cost_usd !== undefined)) {
147
+ totals.cost_usd =
148
+ Math.round(reported.reduce((sum, u) => sum + (u.cost_usd ?? 0), 0) * 1e6) / 1e6;
149
+ }
150
+ return totals;
151
+ }
152
+
153
+ /** "1,234,567 tokens over 96 tiles (3 unreported), $1.23" for log lines. */
154
+ export function formatCritiqueUsage(totals: CritiqueUsageTotals): string {
155
+ const tokens = (totals.input_tokens + totals.output_tokens).toLocaleString("en-US");
156
+ return (
157
+ `${tokens} tokens over ${totals.tiles_reported} tile(s)` +
158
+ (totals.tiles_unreported > 0 ? ` (${totals.tiles_unreported} unreported)` : "") +
159
+ (totals.cost_usd !== undefined ? `, $${totals.cost_usd.toFixed(2)} at list price` : "")
160
+ );
79
161
  }
80
162
 
81
163
  /**
@@ -93,6 +175,9 @@ export type CritiqueProvider = ((input: {
93
175
  url: string;
94
176
  rubric: string;
95
177
  tile: CritiqueTile;
178
+ /** Optional sink for the tokens this call spent. A provider that knows its
179
+ * usage calls it once per backend call; callers that omit it lose nothing. */
180
+ reportUsage?: (usage: CritiqueUsage) => void;
96
181
  }) => Promise<CritiqueFinding[]>) & {
97
182
  concurrency?: number;
98
183
  meta?: () => Record<string, unknown> | undefined;
@@ -242,12 +327,16 @@ export function tilesFromFullPage(
242
327
  pngBase64: PNG.sync.write(dst).toString("base64"),
243
328
  };
244
329
  };
245
- const kept = rects.slice(0, opts.maxTiles);
330
+ const kept = rects.slice(0, Math.min(opts.maxTiles, PAGE_REVIEW_MAX_TILE_BUDGET));
246
331
  const tiles: CritiqueTile[] = kept.map(cutTile);
332
+ const hitBandCap = Math.min(
333
+ opts.maxHitBands ?? 50,
334
+ Math.max(0, PAGE_REVIEW_MAX_TILE_BUDGET - kept.length),
335
+ );
247
336
  let hitBands = 0;
248
337
  const banding = !(opts.elementRects && opts.elementRects.length > 0);
249
338
  if (banding && opts.hitRects && opts.hitRects.length > 0) {
250
- for (const index of bandsCoveringRects(rects, kept.length, opts.hitRects, opts.maxHitBands)) {
339
+ for (const index of bandsCoveringRects(rects, kept.length, opts.hitRects, hitBandCap)) {
251
340
  const band = rects[index];
252
341
  if (!band) continue;
253
342
  tiles.push(cutTile({ ...band, label: `hit band ${index + 1}` }));
@@ -323,10 +412,23 @@ export async function runCritique(args: {
323
412
  "no critiqueProvider injected by the host (see HarneryProgramContext.critiqueProvider)",
324
413
  };
325
414
  }
415
+ if (tiles.length > PAGE_REVIEW_MAX_TILE_BUDGET) {
416
+ return {
417
+ rule: "critique",
418
+ tiles: tiles.length,
419
+ provider: true,
420
+ findings: [],
421
+ outcome: "skipped",
422
+ error:
423
+ `${tiles.length} tiles exceed the per-invocation ceiling of ${PAGE_REVIEW_MAX_TILE_BUDGET} ` +
424
+ "vision calls; lower the tile cap",
425
+ };
426
+ }
326
427
  // Tiles are judged independently, so run them concurrently (bounded) and
327
428
  // reassemble findings in tile order so artifacts stay deterministic.
328
429
  const concurrency = Math.max(1, Math.min(provider.concurrency ?? 4, tiles.length || 1));
329
430
  const perTile: CritiqueFinding[][] = tiles.map(() => []);
431
+ const perTileUsage: Array<CritiqueUsage | undefined> = tiles.map(() => undefined);
330
432
  let cursor = 0;
331
433
  const worker = async (): Promise<void> => {
332
434
  while (true) {
@@ -334,7 +436,14 @@ export async function runCritique(args: {
334
436
  if (i >= tiles.length) return;
335
437
  const tile = tiles[i];
336
438
  try {
337
- const tileFindings = await provider({ url, rubric, tile });
439
+ const tileFindings = await provider({
440
+ url,
441
+ rubric,
442
+ tile,
443
+ reportUsage: (usage) => {
444
+ perTileUsage[i] = addCritiqueUsage(perTileUsage[i], usage);
445
+ },
446
+ });
338
447
  perTile[i] = tileFindings.map((f) => ({ ...f, tile: tile.index }));
339
448
  } catch (err: unknown) {
340
449
  perTile[i] = [
@@ -359,5 +468,6 @@ export async function runCritique(args: {
359
468
  findings,
360
469
  outcome,
361
470
  ...(meta ? { provider_meta: meta } : {}),
471
+ usage: sumCritiqueUsage(perTileUsage),
362
472
  };
363
473
  }