harnery 0.31.6 → 0.32.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 (218) hide show
  1. package/dist/commander.d.ts.map +1 -1
  2. package/dist/commander.js +2 -0
  3. package/dist/commands/agents.d.ts +0 -9
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +70 -95
  6. package/dist/commands/browse-session.d.ts +21 -0
  7. package/dist/commands/browse-session.d.ts.map +1 -0
  8. package/dist/commands/browse-session.js +157 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +205 -37
  11. package/dist/commands/checkpoint.js +1 -1
  12. package/dist/commands/deinit.d.ts.map +1 -1
  13. package/dist/commands/deinit.js +4 -0
  14. package/dist/commands/docs.d.ts.map +1 -1
  15. package/dist/commands/docs.js +40 -0
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +100 -17
  18. package/dist/commands/init.d.ts +1 -2
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +16 -5
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +2 -3
  23. package/dist/core/agents/canonical-emit.d.ts +1 -2
  24. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  25. package/dist/core/agents/canonical-emit.js +1 -2
  26. package/dist/core/agents/cli.js +123 -88
  27. package/dist/core/agents/coord-client.d.ts +7 -3
  28. package/dist/core/agents/coord-client.d.ts.map +1 -1
  29. package/dist/core/agents/coord-client.js +5 -4
  30. package/dist/core/agents/finalization.d.ts +68 -0
  31. package/dist/core/agents/finalization.d.ts.map +1 -0
  32. package/dist/core/agents/finalization.js +443 -0
  33. package/dist/core/agents/git-hook.d.ts +51 -0
  34. package/dist/core/agents/git-hook.d.ts.map +1 -0
  35. package/dist/core/agents/git-hook.js +118 -0
  36. package/dist/core/agents/render/prompt-context.d.ts +6 -5
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +26 -13
  39. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  40. package/dist/core/agents/render/session-context.js +15 -2
  41. package/dist/core/agents/rules/claim-conflict.js +3 -3
  42. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  43. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  44. package/dist/core/agents/rules/commit-conflict.js +21 -5
  45. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  46. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  47. package/dist/core/agents/rules/stop-hook.js +59 -23
  48. package/dist/core/agents/session-events.d.ts +8 -16
  49. package/dist/core/agents/session-events.d.ts.map +1 -1
  50. package/dist/core/agents/session-events.js +12 -26
  51. package/dist/core/agents/state/heartbeat-projector.d.ts +3 -0
  52. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  53. package/dist/core/agents/state/heartbeat-projector.js +44 -1
  54. package/dist/core/agents/state/heartbeat-writer.d.ts +32 -5
  55. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  56. package/dist/core/agents/state/heartbeat-writer.js +54 -17
  57. package/dist/core/agents/state/names.d.ts +22 -1
  58. package/dist/core/agents/state/names.d.ts.map +1 -1
  59. package/dist/core/agents/state/names.js +40 -2
  60. package/dist/core/config.d.ts +30 -2
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +74 -11
  63. package/dist/core/governor/planning.d.ts.map +1 -1
  64. package/dist/core/governor/planning.js +12 -13
  65. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  66. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  67. package/dist/core/hooks/adapter/detect.js +4 -14
  68. package/dist/core/hooks/adapter/output.d.ts +1 -1
  69. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  70. package/dist/core/hooks/adapter/output.js +5 -4
  71. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +32 -0
  74. package/dist/core/hooks/cli.d.ts +1 -2
  75. package/dist/core/hooks/cli.d.ts.map +1 -1
  76. package/dist/core/hooks/cli.js +169 -27
  77. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  78. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  79. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  80. package/dist/core/hooks/effects/index.d.ts +1 -1
  81. package/dist/core/hooks/effects/index.js +3 -3
  82. package/dist/core/hooks/events/schema.d.ts +27 -0
  83. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  84. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  85. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  86. package/dist/core/hooks/resolve/owner.js +15 -0
  87. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  88. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  89. package/dist/core/hooks/resolve/transcript.js +176 -1
  90. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  91. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  92. package/dist/core/hooks/session-name-presence.js +43 -0
  93. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  94. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  95. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  96. package/dist/core/work/state.d.ts +4 -5
  97. package/dist/core/work/state.d.ts.map +1 -1
  98. package/dist/core/work/state.js +5 -8
  99. package/dist/core/workflow/index.d.ts +1 -1
  100. package/dist/core/workflow/index.d.ts.map +1 -1
  101. package/dist/core/workflow/proof.d.ts +2 -2
  102. package/dist/core/workflow/proof.d.ts.map +1 -1
  103. package/dist/core/workflow/proof.js +2 -2
  104. package/dist/core/workflow/run-state.d.ts +2 -2
  105. package/dist/core/workflow/run-state.d.ts.map +1 -1
  106. package/dist/core/workflow/run-state.js +2 -2
  107. package/dist/core/workflow/types.d.ts +3 -3
  108. package/dist/core/workflow/types.d.ts.map +1 -1
  109. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  110. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  111. package/dist/core/workflow/workspaces/execution.js +5 -0
  112. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  113. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  114. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  115. package/dist/core/workflow/workspaces/local-git.js +1 -2
  116. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  117. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  118. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  119. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  120. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  121. package/dist/core/workflow/workspaces/validate.js +0 -2
  122. package/dist/lib/browser/client.d.ts +118 -1
  123. package/dist/lib/browser/client.d.ts.map +1 -1
  124. package/dist/lib/browser/client.js +435 -6
  125. package/dist/lib/browser/geometry.d.ts.map +1 -1
  126. package/dist/lib/browser/geometry.js +180 -37
  127. package/dist/lib/browser/index.d.ts +5 -1
  128. package/dist/lib/browser/index.d.ts.map +1 -1
  129. package/dist/lib/browser/index.js +5 -1
  130. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  131. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  132. package/dist/lib/browser/netscape-cookies.js +31 -0
  133. package/dist/lib/browser/proxy.d.ts +24 -0
  134. package/dist/lib/browser/proxy.d.ts.map +1 -0
  135. package/dist/lib/browser/proxy.js +84 -0
  136. package/dist/lib/browser/runts.d.ts +6 -0
  137. package/dist/lib/browser/runts.d.ts.map +1 -1
  138. package/dist/lib/browser/runts.js +21 -4
  139. package/dist/lib/browser/session-control.d.ts +112 -0
  140. package/dist/lib/browser/session-control.d.ts.map +1 -0
  141. package/dist/lib/browser/session-control.js +670 -0
  142. package/dist/lib/docs-links.d.ts +108 -0
  143. package/dist/lib/docs-links.d.ts.map +1 -0
  144. package/dist/lib/docs-links.js +555 -0
  145. package/dist/lib/exec.d.ts +1 -1
  146. package/dist/lib/exec.js +1 -1
  147. package/dist/lib/identities/assume.d.ts +4 -2
  148. package/dist/lib/identities/assume.d.ts.map +1 -1
  149. package/dist/lib/identities/assume.js +15 -2
  150. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  151. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  152. package/dist/lib/instructions/git-hooks.js +238 -0
  153. package/dist/lib/instructions/splice.d.ts +10 -4
  154. package/dist/lib/instructions/splice.d.ts.map +1 -1
  155. package/dist/lib/instructions/splice.js +19 -11
  156. package/package.json +1 -1
  157. package/schemas/config.schema.json +32 -0
  158. package/src/commander.ts +2 -0
  159. package/src/commands/agents.ts +96 -115
  160. package/src/commands/browse-session.ts +245 -0
  161. package/src/commands/browse.ts +287 -44
  162. package/src/commands/checkpoint.ts +1 -1
  163. package/src/commands/deinit.ts +7 -1
  164. package/src/commands/docs.ts +56 -0
  165. package/src/commands/doctor.ts +100 -18
  166. package/src/commands/init.ts +17 -9
  167. package/src/commands/tunnel.ts +2 -5
  168. package/src/core/agents/canonical-emit.ts +1 -2
  169. package/src/core/agents/cli.ts +137 -88
  170. package/src/core/agents/coord-client.ts +10 -5
  171. package/src/core/agents/finalization.ts +595 -0
  172. package/src/core/agents/git-hook.ts +126 -0
  173. package/src/core/agents/render/prompt-context.ts +28 -13
  174. package/src/core/agents/render/session-context.ts +15 -2
  175. package/src/core/agents/rules/claim-conflict.ts +3 -3
  176. package/src/core/agents/rules/commit-conflict.ts +35 -8
  177. package/src/core/agents/rules/stop-hook.ts +71 -23
  178. package/src/core/agents/session-events.ts +14 -41
  179. package/src/core/agents/state/heartbeat-projector.ts +45 -1
  180. package/src/core/agents/state/heartbeat-writer.ts +78 -16
  181. package/src/core/agents/state/names.ts +54 -2
  182. package/src/core/config.ts +92 -12
  183. package/src/core/governor/planning.ts +17 -12
  184. package/src/core/hooks/adapter/detect.ts +4 -13
  185. package/src/core/hooks/adapter/output.ts +9 -4
  186. package/src/core/hooks/adapter/wiring.ts +43 -0
  187. package/src/core/hooks/cli.ts +201 -23
  188. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  189. package/src/core/hooks/effects/index.ts +5 -5
  190. package/src/core/hooks/events/schema.ts +27 -0
  191. package/src/core/hooks/resolve/owner.ts +22 -1
  192. package/src/core/hooks/resolve/transcript.ts +164 -1
  193. package/src/core/hooks/session-name-presence.ts +52 -0
  194. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  195. package/src/core/work/state.ts +8 -12
  196. package/src/core/workflow/engine.ts +2 -2
  197. package/src/core/workflow/index.ts +0 -1
  198. package/src/core/workflow/proof.ts +4 -4
  199. package/src/core/workflow/run-state.ts +5 -5
  200. package/src/core/workflow/types.ts +3 -3
  201. package/src/core/workflow/workspaces/execution.ts +11 -4
  202. package/src/core/workflow/workspaces/index.ts +0 -3
  203. package/src/core/workflow/workspaces/local-git.ts +1 -2
  204. package/src/core/workflow/workspaces/paths.ts +2 -2
  205. package/src/core/workflow/workspaces/types.ts +0 -9
  206. package/src/core/workflow/workspaces/validate.ts +1 -5
  207. package/src/lib/browser/client.ts +528 -6
  208. package/src/lib/browser/geometry.ts +197 -37
  209. package/src/lib/browser/index.ts +39 -0
  210. package/src/lib/browser/netscape-cookies.ts +39 -0
  211. package/src/lib/browser/proxy.ts +105 -0
  212. package/src/lib/browser/runts.ts +27 -3
  213. package/src/lib/browser/session-control.ts +892 -0
  214. package/src/lib/docs-links.ts +674 -0
  215. package/src/lib/exec.ts +1 -1
  216. package/src/lib/identities/assume.ts +22 -1
  217. package/src/lib/instructions/git-hooks.ts +259 -0
  218. package/src/lib/instructions/splice.ts +41 -11
@@ -198,8 +198,12 @@ export function intersectRects(a: LayoutRect, b: LayoutRect): LayoutRect | null
198
198
  */
199
199
  export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLintResult {
200
200
  return (request) => {
201
- const CHILD_LIMIT = 250;
201
+ const CHILD_LIMIT = 1_000;
202
+ const TEXT_LIMIT = 1_000;
202
203
  const ISSUE_LIMIT = 100;
204
+ // Chromium geometry is fractional even when authored CSS is integral.
205
+ // Differences below half a CSS pixel are rasterization noise, not clipping.
206
+ const SUBPIXEL_EPSILON = 0.5;
203
207
 
204
208
  const rectOf = (rect: DOMRect): LayoutRect => ({
205
209
  x: rect.x,
@@ -233,6 +237,14 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
233
237
  const isHidden = (element: Element): boolean => {
234
238
  let current: Element | null = element;
235
239
  while (current) {
240
+ // A collapsed disclosure hides its content through the ::details-content
241
+ // pseudo, which is not in the ancestor chain, so no computed style on a
242
+ // real ancestor reports it. Without this, every closed accordion panel
243
+ // reads as text clipped out of its container.
244
+ const parent: Element | null = current.parentElement;
245
+ if (parent instanceof HTMLDetailsElement && !parent.open && current.tagName !== "SUMMARY") {
246
+ return true;
247
+ }
236
248
  const style = getComputedStyle(current);
237
249
  const contentVisibility = (style as CSSStyleDeclaration & { contentVisibility?: string })
238
250
  .contentVisibility;
@@ -495,6 +507,29 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
495
507
  return { x: clips.has(style.overflowX), y: clips.has(style.overflowY) };
496
508
  };
497
509
 
510
+ // A scroller hides content the same way a clip does, but the reader can
511
+ // reach it. Content below the fold of a capped table or right of a wide
512
+ // code block is not a layout defect, so an axis stops being checked at the
513
+ // first ancestor that scrolls it. Constraints from ancestors INSIDE that
514
+ // scroller still apply -- an overflow:hidden box within a scroller really
515
+ // does cut its content off, and the scroller cannot reveal it.
516
+ const scrollingStyle = (element: Element): { x: boolean; y: boolean } => {
517
+ const style = getComputedStyle(element);
518
+ const scrolls = new Set(["auto", "scroll"]);
519
+ return { x: scrolls.has(style.overflowX), y: scrolls.has(style.overflowY) };
520
+ };
521
+
522
+ const unbounded = (): LayoutRect => ({
523
+ x: Number.NEGATIVE_INFINITY,
524
+ y: Number.NEGATIVE_INFINITY,
525
+ left: Number.NEGATIVE_INFINITY,
526
+ top: Number.NEGATIVE_INFINITY,
527
+ right: Number.POSITIVE_INFINITY,
528
+ bottom: Number.POSITIVE_INFINITY,
529
+ width: Number.POSITIVE_INFINITY,
530
+ height: Number.POSITIVE_INFINITY,
531
+ });
532
+
498
533
  const paddingRect = (element: Element): LayoutRect => {
499
534
  const rect = element.getBoundingClientRect();
500
535
  const html = element as HTMLElement;
@@ -515,8 +550,8 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
515
550
  };
516
551
 
517
552
  const clip = request.clip.map(({ selector, tolerancePx }): ClipResult => {
518
- const container = document.querySelector(selector);
519
- if (!(container instanceof Element)) {
553
+ const containers = [...document.querySelectorAll(selector)];
554
+ if (containers.length === 0) {
520
555
  return {
521
556
  rule: "clip" as const,
522
557
  selector,
@@ -532,31 +567,59 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
532
567
  const unsupported = new Set<string>();
533
568
  const excluded: LayoutExclusion[] = [];
534
569
  const issues: ClipIssue[] = [];
535
- const descendants = [...container.querySelectorAll("*")];
536
- let truncated = descendants.length > CHILD_LIMIT;
537
- descendants.slice(0, CHILD_LIMIT).forEach((element, index) => {
538
- if (isHidden(element)) {
539
- excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "hidden" });
540
- return;
541
- }
542
- const rect = element.getBoundingClientRect();
543
- if (rect.width <= 0 || rect.height <= 0) {
544
- excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "zero-area" });
545
- return;
570
+ let truncated = false;
571
+ let measurementIndex = 0;
572
+
573
+ const recordIssue = (
574
+ element: LayoutElementMeasurement,
575
+ allowed: LayoutRect,
576
+ clippedBy: string,
577
+ axes: { x: boolean; y: boolean } = { x: true, y: true },
578
+ ): void => {
579
+ const rect = element.rect;
580
+ const overrun = {
581
+ top: axes.y ? Math.max(0, allowed.top - rect.top) : 0,
582
+ right: axes.x ? Math.max(0, rect.right - allowed.right) : 0,
583
+ bottom: axes.y ? Math.max(0, rect.bottom - allowed.bottom) : 0,
584
+ left: axes.x ? Math.max(0, allowed.left - rect.left) : 0,
585
+ };
586
+ const maxOverrunPx = Math.max(overrun.top, overrun.right, overrun.bottom, overrun.left);
587
+ if (maxOverrunPx > tolerancePx + SUBPIXEL_EPSILON && issues.length < ISSUE_LIMIT) {
588
+ issues.push({ element, clippedBy, overrun, maxOverrunPx });
546
589
  }
547
- let allowed = paddingRect(container);
548
- let clippedBy = labelOf(container);
549
- let current: Element | null = element.parentElement;
590
+ };
591
+
592
+ // Walk from `start` up to `container`, intersecting the allowed rect with
593
+ // every ancestor that genuinely clips. `free` records an axis the reader
594
+ // can scroll, after which nothing further constrains that axis -- the
595
+ // scroller's own box still has to fit its outer chain, but its contents
596
+ // are reachable and so are exempt.
597
+ const clipChain = (
598
+ start: Element | null,
599
+ container: Element,
600
+ seed: LayoutRect,
601
+ seedLabel: string,
602
+ ): { allowed: LayoutRect; clippedBy: string } => {
603
+ let allowed = { ...seed };
604
+ let clippedBy = seedLabel;
605
+ const free = { x: false, y: false };
606
+ let current: Element | null = start;
550
607
  while (current && container.contains(current)) {
551
608
  const style = getComputedStyle(current);
552
609
  if (style.clipPath && style.clipPath !== "none")
553
610
  unsupported.add(`${labelOf(current)}:clip-path`);
554
- if (style.borderRadius && !/^0(?:px)?(?: 0(?:px)?){0,3}$/.test(style.borderRadius)) {
555
- unsupported.add(`${labelOf(current)}:border-radius`);
556
- }
557
611
  if (style.transform && style.transform !== "none")
558
612
  unsupported.add(`${labelOf(current)}:transform`);
559
- const clips = current === container ? { x: true, y: true } : clippingStyle(current);
613
+ // The container bounds by default even when it does not CSS-clip,
614
+ // since it is the scope the caller asked about -- but a scroller
615
+ // hands its axis back whether it is the scope or an ancestor.
616
+ const scope = current === container;
617
+ const scrolls = scrollingStyle(current);
618
+ const css = scope ? { x: true, y: true } : clippingStyle(current);
619
+ const clips = {
620
+ x: css.x && !free.x && !scrolls.x,
621
+ y: css.y && !free.y && !scrolls.y,
622
+ };
560
623
  if (clips.x || clips.y) {
561
624
  const candidate = paddingRect(current);
562
625
  allowed = {
@@ -573,26 +636,123 @@ export function buildLayoutLintCheck(): (request: LayoutLintRequest) => LayoutLi
573
636
  allowed.height = Math.max(0, allowed.bottom - allowed.top);
574
637
  clippedBy = labelOf(current);
575
638
  }
576
- if (current === container) break;
639
+ if (scrolls.x) free.x = true;
640
+ if (scrolls.y) free.y = true;
641
+ if (scope) break;
577
642
  current = current.parentElement;
578
643
  }
579
- const overrun = {
580
- top: Math.max(0, allowed.top - rect.top),
581
- right: Math.max(0, rect.right - allowed.right),
582
- bottom: Math.max(0, rect.bottom - allowed.bottom),
583
- left: Math.max(0, allowed.left - rect.left),
584
- };
585
- const maxOverrunPx = Math.max(overrun.top, overrun.right, overrun.bottom, overrun.left);
586
- if (maxOverrunPx > tolerancePx && issues.length < ISSUE_LIMIT) {
587
- issues.push({
588
- element: measure(element, index, false),
589
- clippedBy,
590
- overrun,
591
- maxOverrunPx,
592
- });
644
+ return { allowed, clippedBy };
645
+ };
646
+
647
+ const textContainer = (node: Node, scope: Element): Element | null => {
648
+ let current = node.parentElement;
649
+ while (current && scope.contains(current)) {
650
+ if (current instanceof SVGElement) return null;
651
+ const display = getComputedStyle(current).display;
652
+ if (display !== "inline" && display !== "contents") return current;
653
+ if (current === scope) return current;
654
+ current = current.parentElement;
593
655
  }
594
- });
656
+ return scope;
657
+ };
658
+
659
+ for (const container of containers) {
660
+ const descendants = [...container.querySelectorAll("*")];
661
+ if (descendants.length > CHILD_LIMIT) truncated = true;
662
+ descendants.slice(0, CHILD_LIMIT).forEach((element) => {
663
+ const index = measurementIndex++;
664
+ if (isHidden(element)) {
665
+ excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "hidden" });
666
+ return;
667
+ }
668
+ const rect = element.getBoundingClientRect();
669
+ if (rect.width <= 0 || rect.height <= 0) {
670
+ excluded.push({ index, tag: element.tagName.toLowerCase(), reason: "zero-area" });
671
+ return;
672
+ }
673
+ const chain = clipChain(
674
+ element.parentElement,
675
+ container,
676
+ unbounded(),
677
+ labelOf(container),
678
+ );
679
+ recordIssue(measure(element, index, false), chain.allowed, chain.clippedBy);
680
+ });
681
+
682
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
683
+ let textCount = 0;
684
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
685
+ if (textCount >= TEXT_LIMIT) {
686
+ truncated = true;
687
+ break;
688
+ }
689
+ const value = node.nodeValue ?? "";
690
+ if (!/\S/.test(value)) continue;
691
+ const parent = node.parentElement;
692
+ if (
693
+ !parent ||
694
+ ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(parent.tagName) ||
695
+ isHidden(parent)
696
+ ) {
697
+ continue;
698
+ }
699
+ const owner = textContainer(node, container);
700
+ if (!owner) continue;
701
+ let transformed = false;
702
+ let current: Element | null = owner;
703
+ while (current && container.contains(current)) {
704
+ const transform = getComputedStyle(current).transform;
705
+ if (transform && transform !== "none") {
706
+ unsupported.add(`${labelOf(current)}:transform`);
707
+ transformed = true;
708
+ break;
709
+ }
710
+ if (current === container) break;
711
+ current = current.parentElement;
712
+ }
713
+ if (transformed) continue;
714
+ textCount++;
715
+ // Text glyph boxes routinely extend a few pixels above/below their
716
+ // line box. That is normal font paint, not clipping. Constrain text
717
+ // horizontally to its nearest block owner, vertically to the scope,
718
+ // and on either axis where a CSS ancestor actually clips.
719
+ const seed = unbounded();
720
+ // Constrain text horizontally to its nearest block owner -- unless
721
+ // that owner scrolls sideways, where running past its edge is how a
722
+ // long line is meant to behave.
723
+ if (!scrollingStyle(owner).x) {
724
+ const ownerRect = paddingRect(owner);
725
+ seed.left = ownerRect.left;
726
+ seed.right = ownerRect.right;
727
+ seed.x = seed.left;
728
+ seed.width = Math.max(0, seed.right - seed.left);
729
+ }
730
+ const chain = clipChain(owner, container, seed, labelOf(owner));
731
+ const allowed = chain.allowed;
732
+ const clippedBy = chain.clippedBy;
733
+ const range = document.createRange();
734
+ range.selectNodeContents(node);
735
+ for (const fragment of [...range.getClientRects()]) {
736
+ if (fragment.width <= 0 || fragment.height <= 0) continue;
737
+ const index = measurementIndex++;
738
+ recordIssue(
739
+ {
740
+ index,
741
+ tag: parent.tagName.toLowerCase(),
742
+ label: labelOf(parent),
743
+ snippet: value.replace(/\s+/g, " ").trim().slice(0, 160),
744
+ source: "text",
745
+ rect: rectOf(fragment),
746
+ },
747
+ allowed,
748
+ clippedBy,
749
+ );
750
+ }
751
+ }
752
+ }
595
753
  if (issues.length >= ISSUE_LIMIT) truncated = true;
754
+ // Border radius keeps the rectangular padding-box contract; only shapes
755
+ // the rectangle cannot certify contribute to unsupported geometry.
596
756
  const outcome: LayoutOutcome =
597
757
  issues.length > 0 ? "fail" : unsupported.size > 0 ? "unknown" : "pass";
598
758
  return {
@@ -8,6 +8,15 @@ export {
8
8
  export {
9
9
  Browser,
10
10
  type BrowserOptions,
11
+ BrowserSessionActionError,
12
+ type BrowserSessionControl,
13
+ type BrowserSessionInspection,
14
+ type BrowserSessionLocator,
15
+ type BrowserSessionNavigation,
16
+ type BrowserSessionNavigationType,
17
+ type BrowserSessionScreenshot,
18
+ type BrowserSessionStatus,
19
+ type BrowserSessionTab,
11
20
  type ConsoleEvent,
12
21
  type Diagnostics,
13
22
  type FailedRequest,
@@ -65,16 +74,46 @@ export type {
65
74
  OverlapIssue,
66
75
  OverlapResult,
67
76
  } from "./geometry.js";
77
+ export { buildLayoutLintCheck } from "./geometry.js";
68
78
  export { isWSL, wslHeadedLaunchArgs } from "./launch-args.js";
69
79
  export type {
70
80
  OverflowElement,
71
81
  OverflowResult,
72
82
  WidthResult,
73
83
  } from "./layout.js";
84
+ export {
85
+ serializeNetscapeCookies,
86
+ writeNetscapeCookieFile,
87
+ } from "./netscape-cookies.js";
88
+ export {
89
+ type BrowserProxy,
90
+ type BrowserProxyGate,
91
+ browserProxyFromEnv,
92
+ browserProxyGateFromEnv,
93
+ extractObservedIp,
94
+ WEBRTC_PROXY_ONLY_ARG,
95
+ } from "./proxy.js";
74
96
  export type {
75
97
  RuntHit,
76
98
  RuntsResult,
77
99
  } from "./runts.js";
100
+ export {
101
+ BROWSER_SESSION_MAX_FILL_BYTES,
102
+ BROWSER_SESSION_MAX_FRAME_BYTES,
103
+ BROWSER_SESSION_MAX_INSPECTION_TEXT_BYTES,
104
+ BROWSER_SESSION_PROTOCOL_VERSION,
105
+ type BrowserSessionDescriptor,
106
+ BrowserSessionError,
107
+ type BrowserSessionRequest,
108
+ type BrowserSessionResponse,
109
+ type BrowserSessionResult,
110
+ type BrowserSessionServer,
111
+ type BrowserSessionTarget,
112
+ browserSessionTransport,
113
+ readBrowserSessionDescriptor,
114
+ sendBrowserSessionRequest,
115
+ startBrowserSessionServer,
116
+ } from "./session-control.js";
78
117
  export type {
79
118
  TargetSizeNode,
80
119
  TargetSizeOutcome,
@@ -0,0 +1,39 @@
1
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import type { Cookie } from "playwright";
4
+
5
+ function cookieField(value: string): string {
6
+ return value.replace(/[\t\r\n]/g, "");
7
+ }
8
+
9
+ /** Serialize Playwright cookies in the Netscape cookies.txt format used by yt-dlp. */
10
+ export function serializeNetscapeCookies(cookies: readonly Cookie[]): string {
11
+ const lines = ["# Netscape HTTP Cookie File", "# Generated by harn browse. Keep private.", ""];
12
+ for (const cookie of [...cookies].sort((a, b) =>
13
+ `${a.domain}\0${a.path}\0${a.name}`.localeCompare(`${b.domain}\0${b.path}\0${b.name}`),
14
+ )) {
15
+ const domain = `${cookie.httpOnly ? "#HttpOnly_" : ""}${cookieField(cookie.domain)}`;
16
+ const includeSubdomains = cookie.domain.startsWith(".") ? "TRUE" : "FALSE";
17
+ const secure = cookie.secure ? "TRUE" : "FALSE";
18
+ const expires = cookie.expires > 0 ? String(Math.floor(cookie.expires)) : "0";
19
+ lines.push(
20
+ [
21
+ domain,
22
+ includeSubdomains,
23
+ cookieField(cookie.path || "/"),
24
+ secure,
25
+ expires,
26
+ cookieField(cookie.name),
27
+ cookieField(cookie.value),
28
+ ].join("\t"),
29
+ );
30
+ }
31
+ return `${lines.join("\n")}\n`;
32
+ }
33
+
34
+ /** Write a credential-bearing cookie file with owner-only permissions. */
35
+ export function writeNetscapeCookieFile(path: string, cookies: readonly Cookie[]): void {
36
+ mkdirSync(dirname(path), { recursive: true });
37
+ writeFileSync(path, serializeNetscapeCookies(cookies), { mode: 0o600 });
38
+ chmodSync(path, 0o600);
39
+ }
@@ -0,0 +1,105 @@
1
+ export interface BrowserProxy {
2
+ server: string;
3
+ username?: string;
4
+ password?: string;
5
+ }
6
+
7
+ export interface BrowserProxyGate {
8
+ checkUrl: string;
9
+ expectedIp: string;
10
+ }
11
+
12
+ const PROXY_ENV_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"] as const;
13
+
14
+ /**
15
+ * Parse an authenticated HTTP(S) proxy from the child environment without
16
+ * requiring credentials in command arguments. The returned server never
17
+ * contains userinfo, so Playwright owns the 407 challenge response.
18
+ */
19
+ export function browserProxyFromEnv(env: NodeJS.ProcessEnv = process.env): BrowserProxy {
20
+ const raw = PROXY_ENV_KEYS.map((key) => env[key]).find(
21
+ (value): value is string => typeof value === "string" && value.trim().length > 0,
22
+ );
23
+ if (!raw) {
24
+ throw new Error(
25
+ "--proxy-from-env requires HTTPS_PROXY, https_proxy, HTTP_PROXY, or http_proxy.",
26
+ );
27
+ }
28
+
29
+ let url: URL;
30
+ try {
31
+ url = new URL(raw);
32
+ } catch {
33
+ throw new Error("The proxy environment contains an invalid URL.");
34
+ }
35
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
36
+ throw new Error("Browser proxy URLs must use http:// or https://.");
37
+ }
38
+ if (!url.hostname || !url.port || url.pathname !== "/" || url.search || url.hash) {
39
+ throw new Error(
40
+ "Browser proxy URLs must contain only scheme, host, port, and optional credentials.",
41
+ );
42
+ }
43
+ if ((url.username && !url.password) || (!url.username && url.password)) {
44
+ throw new Error("Browser proxy URLs must provide both username and password, or neither.");
45
+ }
46
+
47
+ const proxy: BrowserProxy = { server: `${url.protocol}//${url.host}` };
48
+ if (url.username) {
49
+ proxy.username = decodeURIComponent(url.username);
50
+ proxy.password = decodeURIComponent(url.password);
51
+ }
52
+ return proxy;
53
+ }
54
+
55
+ /**
56
+ * Read the optional fixed-egress gate injected by an embedding host wrapper.
57
+ * Both fields are required together so a partial contract
58
+ * fails before Chromium visits the requested target.
59
+ */
60
+ export function browserProxyGateFromEnv(
61
+ env: NodeJS.ProcessEnv = process.env,
62
+ ): BrowserProxyGate | null {
63
+ const expectedIp = env.HARNERY_BROWSER_PROXY_EXPECTED_IP?.trim();
64
+ const checkUrl = env.HARNERY_BROWSER_PROXY_CHECK_URL?.trim();
65
+ if (!expectedIp && !checkUrl) return null;
66
+ if (!expectedIp || !checkUrl) {
67
+ throw new Error(
68
+ "HARNERY_BROWSER_PROXY_EXPECTED_IP and HARNERY_BROWSER_PROXY_CHECK_URL must be set together.",
69
+ );
70
+ }
71
+ if (!/^(?:\d{1,3}\.){3}\d{1,3}$/.test(expectedIp)) {
72
+ throw new Error("HARNERY_BROWSER_PROXY_EXPECTED_IP must be an IPv4 address.");
73
+ }
74
+ let parsed: URL;
75
+ try {
76
+ parsed = new URL(checkUrl);
77
+ } catch {
78
+ throw new Error("HARNERY_BROWSER_PROXY_CHECK_URL must be a valid HTTPS URL.");
79
+ }
80
+ if (parsed.protocol !== "https:") {
81
+ throw new Error("HARNERY_BROWSER_PROXY_CHECK_URL must use HTTPS.");
82
+ }
83
+ return { expectedIp, checkUrl: parsed.toString() };
84
+ }
85
+
86
+ export function extractObservedIp(body: string): string | null {
87
+ try {
88
+ const parsed = JSON.parse(body) as Record<string, unknown>;
89
+ for (const key of ["ip", "query", "origin"]) {
90
+ const value = parsed[key];
91
+ if (typeof value === "string" && value.trim()) return value.trim().split(",")[0].trim();
92
+ }
93
+ const proxy = parsed.proxy;
94
+ if (proxy && typeof proxy === "object") {
95
+ const value = (proxy as Record<string, unknown>).ip;
96
+ if (typeof value === "string" && value.trim()) return value.trim();
97
+ }
98
+ } catch {
99
+ // A plain-text IP endpoint is also valid.
100
+ }
101
+ const trimmed = body.trim();
102
+ return /^(?:\d{1,3}\.){3}\d{1,3}$/.test(trimmed) ? trimmed : null;
103
+ }
104
+
105
+ export const WEBRTC_PROXY_ONLY_ARG = "--force-webrtc-ip-handling-policy=disable_non_proxied_udp";
@@ -31,6 +31,12 @@ export interface RuntHit {
31
31
  }
32
32
 
33
33
  export interface RuntsResult {
34
+ /** Requested selector scope, or null for the document body. */
35
+ scope: string | null;
36
+ /** False when a requested selector matched nothing. */
37
+ found: boolean;
38
+ /** Unknown means the sweep hit a safety cap before proving the page clean. */
39
+ outcome: "pass" | "fail" | "unknown";
34
40
  /** Text blocks scanned (post length/inline-children filters). */
35
41
  scannedBlocks: number;
36
42
  /** True when the block sweep hit the internal cap (very large page). */
@@ -71,7 +77,16 @@ export function buildRuntsCheck(): (args: {
71
77
  const RUNT_CAP = 50;
72
78
 
73
79
  const root = scope ? document.querySelector(scope) : document.body;
74
- if (!root) return { scannedBlocks: 0, truncated: false, runts: [] };
80
+ if (!root) {
81
+ return {
82
+ scope,
83
+ found: false,
84
+ outcome: "fail",
85
+ scannedBlocks: 0,
86
+ truncated: false,
87
+ runts: [],
88
+ };
89
+ }
75
90
 
76
91
  const sx = window.scrollX;
77
92
  const sy = window.scrollY;
@@ -84,7 +99,9 @@ export function buildRuntsCheck(): (args: {
84
99
  return tag;
85
100
  };
86
101
 
87
- const all = root.querySelectorAll("*");
102
+ // Include the scope itself. A caller commonly points directly at a <p>,
103
+ // caption, or blurb; querySelectorAll("*") would otherwise scan nothing.
104
+ const all = [root, ...root.querySelectorAll("*")];
88
105
  const runts: RuntHit[] = [];
89
106
  let scanned = 0;
90
107
  let truncated = false;
@@ -158,7 +175,14 @@ export function buildRuntsCheck(): (args: {
158
175
  }
159
176
  }
160
177
 
161
- return { scannedBlocks: scanned, truncated, runts };
178
+ return {
179
+ scope,
180
+ found: true,
181
+ outcome: runts.length > 0 ? "fail" : truncated ? "unknown" : "pass",
182
+ scannedBlocks: scanned,
183
+ truncated,
184
+ runts,
185
+ };
162
186
  };
163
187
  }
164
188