harnery 0.45.1 → 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 (332) 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/commands/workflow.d.ts.map +1 -1
  21. package/dist/commands/workflow.js +6 -45
  22. package/dist/core/agents/coord-client.d.ts.map +1 -1
  23. package/dist/core/agents/coord-client.js +0 -18
  24. package/dist/core/agents/index.d.ts +1 -0
  25. package/dist/core/agents/index.d.ts.map +1 -1
  26. package/dist/core/agents/index.js +1 -0
  27. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  28. package/dist/core/agents/live-authority-v3.js +11 -4
  29. package/dist/core/agents/render/session-context.d.ts +0 -6
  30. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  31. package/dist/core/agents/render/session-context.js +1 -6
  32. package/dist/core/agents/session-events.d.ts.map +1 -1
  33. package/dist/core/agents/session-events.js +12 -4
  34. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  35. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  36. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  37. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  38. package/dist/core/agents/state/live-coordination-view.js +6 -2
  39. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  40. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  41. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  42. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  43. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  44. package/dist/core/agents/state/parent-reference.js +119 -0
  45. package/dist/core/agents/state/pidmap.js +2 -2
  46. package/dist/core/agents/state/proc-start.d.ts +20 -31
  47. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  48. package/dist/core/agents/state/proc-start.js +37 -70
  49. package/dist/core/agents/state/service-process.d.ts +61 -0
  50. package/dist/core/agents/state/service-process.d.ts.map +1 -0
  51. package/dist/core/agents/state/service-process.js +143 -0
  52. package/dist/core/config.d.ts +39 -0
  53. package/dist/core/config.d.ts.map +1 -1
  54. package/dist/core/config.js +63 -0
  55. package/dist/core/context/index.d.ts +6 -4
  56. package/dist/core/context/index.d.ts.map +1 -1
  57. package/dist/core/context/index.js +194 -256
  58. package/dist/core/context/observation.d.ts +9 -9
  59. package/dist/core/context/observation.d.ts.map +1 -1
  60. package/dist/core/context/observation.js +37 -6
  61. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  62. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  63. package/dist/core/diagnostics/path-identity.js +51 -6
  64. package/dist/core/events/v3/canonical.js +43 -14
  65. package/dist/core/events/v3/index.d.ts +1 -1
  66. package/dist/core/events/v3/index.d.ts.map +1 -1
  67. package/dist/core/events/v3/index.js +1 -1
  68. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  69. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  70. package/dist/core/events/v3/producers/recorder.js +117 -13
  71. package/dist/core/events/v3/reader.d.ts.map +1 -1
  72. package/dist/core/events/v3/reader.js +53 -13
  73. package/dist/core/events/v3/runtime-build.d.ts +14 -0
  74. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  75. package/dist/core/events/v3/runtime-build.js +44 -0
  76. package/dist/core/events/v3/validate.d.ts.map +1 -1
  77. package/dist/core/events/v3/validate.js +36 -1
  78. package/dist/core/governor/index.d.ts +2 -1
  79. package/dist/core/governor/index.d.ts.map +1 -1
  80. package/dist/core/governor/index.js +2 -1
  81. package/dist/core/governor/service-read.d.ts +3 -10
  82. package/dist/core/governor/service-read.d.ts.map +1 -1
  83. package/dist/core/governor/service-read.js +10 -94
  84. package/dist/core/governor/service.d.ts +3 -7
  85. package/dist/core/governor/service.d.ts.map +1 -1
  86. package/dist/core/governor/service.js +17 -182
  87. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  88. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  89. package/dist/core/hooks/adapter/native-parent.js +91 -0
  90. package/dist/core/hooks/cli.js +79 -4
  91. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  92. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  93. package/dist/core/hooks/resolve/owner.js +2 -25
  94. package/dist/core/hooks/shell-admission.d.ts +84 -0
  95. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  96. package/dist/core/hooks/shell-admission.js +622 -0
  97. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  98. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  99. package/dist/core/hooks/shell-commit-tree.js +104 -0
  100. package/dist/core/inbox/contract.d.ts +12 -0
  101. package/dist/core/inbox/contract.d.ts.map +1 -1
  102. package/dist/core/inbox/contract.js +16 -0
  103. package/dist/core/inbox/peer-messages.d.ts +41 -16
  104. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  105. package/dist/core/inbox/peer-messages.js +372 -15
  106. package/dist/core/inbox/service.d.ts +8 -1
  107. package/dist/core/inbox/service.d.ts.map +1 -1
  108. package/dist/core/inbox/service.js +83 -3
  109. package/dist/core/inbox/targets.d.ts +17 -3
  110. package/dist/core/inbox/targets.d.ts.map +1 -1
  111. package/dist/core/inbox/targets.js +77 -2
  112. package/dist/core/presence/git.d.ts +18 -12
  113. package/dist/core/presence/git.d.ts.map +1 -1
  114. package/dist/core/presence/git.js +32 -38
  115. package/dist/core/presence/index.d.ts +2 -2
  116. package/dist/core/presence/index.d.ts.map +1 -1
  117. package/dist/core/presence/index.js +8 -6
  118. package/dist/core/presence/mail.d.ts +133 -0
  119. package/dist/core/presence/mail.d.ts.map +1 -0
  120. package/dist/core/presence/mail.js +464 -0
  121. package/dist/core/resources/sampler.js +2 -2
  122. package/dist/core/resources/service-status.d.ts +2 -14
  123. package/dist/core/resources/service-status.d.ts.map +1 -1
  124. package/dist/core/resources/service-status.js +3 -23
  125. package/dist/core/resources/service.d.ts +3 -7
  126. package/dist/core/resources/service.d.ts.map +1 -1
  127. package/dist/core/resources/service.js +12 -101
  128. package/dist/core/resources/status.d.ts.map +1 -1
  129. package/dist/core/resources/status.js +3 -2
  130. package/dist/core/semantic/contract.d.ts +5 -5
  131. package/dist/core/semantic/service.d.ts.map +1 -1
  132. package/dist/core/semantic/service.js +9 -2
  133. package/dist/core/servers/index.d.ts +0 -2
  134. package/dist/core/servers/index.d.ts.map +1 -1
  135. package/dist/core/servers/index.js +3 -18
  136. package/dist/core/storage/modes.d.ts +4 -0
  137. package/dist/core/storage/modes.d.ts.map +1 -1
  138. package/dist/core/storage/modes.js +4 -0
  139. package/dist/core/storage/segments.d.ts.map +1 -1
  140. package/dist/core/storage/segments.js +5 -4
  141. package/dist/core/supervisor/service.d.ts +4 -7
  142. package/dist/core/supervisor/service.d.ts.map +1 -1
  143. package/dist/core/supervisor/service.js +23 -122
  144. package/dist/core/supervisor/services.js +2 -2
  145. package/dist/core/supervisor/status.d.ts +3 -17
  146. package/dist/core/supervisor/status.d.ts.map +1 -1
  147. package/dist/core/supervisor/status.js +7 -70
  148. package/dist/core/workflow/engine.js +2 -1
  149. package/dist/core/workflow/index.d.ts +3 -4
  150. package/dist/core/workflow/index.d.ts.map +1 -1
  151. package/dist/core/workflow/index.js +2 -2
  152. package/dist/core/workflow/proof.d.ts.map +1 -1
  153. package/dist/core/workflow/proof.js +30 -74
  154. package/dist/core/workflow/resume-lock.d.ts +17 -0
  155. package/dist/core/workflow/resume-lock.d.ts.map +1 -0
  156. package/dist/core/workflow/resume-lock.js +158 -0
  157. package/dist/core/workflow/run-state.d.ts +0 -21
  158. package/dist/core/workflow/run-state.d.ts.map +1 -1
  159. package/dist/core/workflow/run-state.js +3 -174
  160. package/dist/core/workflow/workspaces/leases.d.ts +4 -0
  161. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  162. package/dist/core/workflow/workspaces/leases.js +1 -1
  163. package/dist/core/workflow/workspaces/local-git.js +91 -210
  164. package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
  165. package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
  166. package/dist/core/workflow/workspaces/ownership.js +3 -130
  167. package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
  168. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
  169. package/dist/core/workflow/workspaces/reclaim.js +0 -4
  170. package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
  171. package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
  172. package/dist/core/workflow/workspaces/recovery.js +1 -1
  173. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  174. package/dist/core/workflow/workspaces/state.js +1 -4
  175. package/dist/lib/admission-history.d.ts +96 -0
  176. package/dist/lib/admission-history.d.ts.map +1 -0
  177. package/dist/lib/admission-history.js +196 -0
  178. package/dist/lib/admission.d.ts +56 -7
  179. package/dist/lib/admission.d.ts.map +1 -1
  180. package/dist/lib/admission.js +161 -19
  181. package/dist/lib/browser/client.d.ts +18 -0
  182. package/dist/lib/browser/client.d.ts.map +1 -1
  183. package/dist/lib/browser/client.js +100 -12
  184. package/dist/lib/browser/content-checks.d.ts +3 -1
  185. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  186. package/dist/lib/browser/content-checks.js +10 -2
  187. package/dist/lib/browser/critique.d.ts +32 -0
  188. package/dist/lib/browser/critique.d.ts.map +1 -1
  189. package/dist/lib/browser/critique.js +72 -3
  190. package/dist/lib/browser/geometry.d.ts +2 -0
  191. package/dist/lib/browser/geometry.d.ts.map +1 -1
  192. package/dist/lib/browser/geometry.js +61 -15
  193. package/dist/lib/browser/index.d.ts +1 -1
  194. package/dist/lib/browser/index.d.ts.map +1 -1
  195. package/dist/lib/browser/index.js +1 -1
  196. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  197. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  198. package/dist/lib/browser/page-review-contracts.js +6 -1
  199. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  200. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  201. package/dist/lib/browser/page-review-judge.js +41 -11
  202. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  203. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  204. package/dist/lib/browser/page-review-pack.js +5 -2
  205. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  206. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  207. package/dist/lib/browser/qa-run-contracts.js +11 -4
  208. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  209. package/dist/lib/browser/qa-run.js +73 -16
  210. package/dist/lib/browser/runts.d.ts +4 -2
  211. package/dist/lib/browser/runts.d.ts.map +1 -1
  212. package/dist/lib/browser/runts.js +84 -33
  213. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  214. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  215. package/dist/lib/browser/windows-chrome.js +42 -0
  216. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  217. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  218. package/dist/lib/browser/windows-login-sync.js +171 -0
  219. package/dist/lib/cookies/client.d.ts +30 -5
  220. package/dist/lib/cookies/client.d.ts.map +1 -1
  221. package/dist/lib/cookies/client.js +55 -10
  222. package/dist/lib/cookies/index.d.ts +1 -1
  223. package/dist/lib/cookies/index.d.ts.map +1 -1
  224. package/dist/lib/cookies/index.js +1 -1
  225. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  226. package/dist/lib/docs-metadata-sync.js +41 -2
  227. package/dist/lib/durable-job.d.ts +8 -0
  228. package/dist/lib/durable-job.d.ts.map +1 -1
  229. package/dist/lib/durable-job.js +10 -0
  230. package/dist/lib/http/client.d.ts +9 -8
  231. package/dist/lib/http/client.d.ts.map +1 -1
  232. package/dist/lib/http/client.js +44 -2
  233. package/dist/lib/instructions/templates.js +4 -4
  234. package/package.json +2 -2
  235. package/src/commander.ts +13 -0
  236. package/src/commands/admission.ts +272 -29
  237. package/src/commands/agents.ts +132 -27
  238. package/src/commands/browse.ts +208 -6
  239. package/src/commands/fetch.ts +1 -0
  240. package/src/commands/ledger-v3.ts +8 -2
  241. package/src/commands/presence.ts +49 -0
  242. package/src/commands/qa-run.ts +4 -3
  243. package/src/commands/review-pack.ts +41 -5
  244. package/src/commands/workflow.ts +8 -58
  245. package/src/core/agents/coord-client.ts +0 -20
  246. package/src/core/agents/index.ts +6 -0
  247. package/src/core/agents/live-authority-v3.ts +11 -4
  248. package/src/core/agents/render/session-context.ts +1 -7
  249. package/src/core/agents/session-events.ts +18 -3
  250. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  251. package/src/core/agents/state/live-coordination-view.ts +13 -6
  252. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  253. package/src/core/agents/state/parent-reference.ts +179 -0
  254. package/src/core/agents/state/pidmap.ts +2 -2
  255. package/src/core/agents/state/proc-start.ts +41 -69
  256. package/src/core/agents/state/service-process.ts +217 -0
  257. package/src/core/config.ts +85 -0
  258. package/src/core/context/index.ts +207 -272
  259. package/src/core/context/observation.ts +40 -9
  260. package/src/core/diagnostics/path-identity.ts +52 -6
  261. package/src/core/events/v3/canonical.ts +46 -13
  262. package/src/core/events/v3/index.ts +3 -0
  263. package/src/core/events/v3/producers/recorder.ts +151 -13
  264. package/src/core/events/v3/reader.ts +55 -11
  265. package/src/core/events/v3/runtime-build.ts +62 -0
  266. package/src/core/events/v3/validate.ts +42 -1
  267. package/src/core/governor/index.ts +1 -2
  268. package/src/core/governor/service-read.ts +15 -114
  269. package/src/core/governor/service.ts +36 -220
  270. package/src/core/hooks/adapter/native-parent.ts +109 -0
  271. package/src/core/hooks/cli.ts +83 -4
  272. package/src/core/hooks/resolve/owner.ts +2 -25
  273. package/src/core/hooks/shell-admission.ts +634 -0
  274. package/src/core/hooks/shell-commit-tree.ts +102 -0
  275. package/src/core/inbox/contract.ts +18 -0
  276. package/src/core/inbox/peer-messages.ts +501 -15
  277. package/src/core/inbox/service.ts +108 -3
  278. package/src/core/inbox/targets.ts +94 -3
  279. package/src/core/presence/git.ts +45 -37
  280. package/src/core/presence/index.ts +16 -8
  281. package/src/core/presence/mail.ts +620 -0
  282. package/src/core/resources/sampler.ts +3 -2
  283. package/src/core/resources/service-status.ts +6 -35
  284. package/src/core/resources/service.ts +23 -133
  285. package/src/core/resources/status.ts +3 -2
  286. package/src/core/semantic/service.ts +8 -2
  287. package/src/core/servers/index.ts +3 -23
  288. package/src/core/storage/modes.ts +6 -0
  289. package/src/core/storage/segments.ts +5 -4
  290. package/src/core/supervisor/service.ts +33 -155
  291. package/src/core/supervisor/services.ts +2 -2
  292. package/src/core/supervisor/status.ts +10 -85
  293. package/src/core/workflow/engine.ts +1 -1
  294. package/src/core/workflow/index.ts +2 -13
  295. package/src/core/workflow/proof.ts +35 -83
  296. package/src/core/workflow/resume-lock.ts +179 -0
  297. package/src/core/workflow/run-state.ts +3 -207
  298. package/src/core/workflow/workspaces/leases.ts +1 -1
  299. package/src/core/workflow/workspaces/local-git.ts +120 -239
  300. package/src/core/workflow/workspaces/ownership.ts +3 -158
  301. package/src/core/workflow/workspaces/reclaim.ts +3 -9
  302. package/src/core/workflow/workspaces/recovery.ts +1 -1
  303. package/src/core/workflow/workspaces/state.ts +1 -5
  304. package/src/lib/admission-history.ts +267 -0
  305. package/src/lib/admission.ts +217 -19
  306. package/src/lib/browser/client.ts +147 -35
  307. package/src/lib/browser/content-checks.ts +14 -3
  308. package/src/lib/browser/critique.ts +113 -3
  309. package/src/lib/browser/geometry.ts +63 -14
  310. package/src/lib/browser/index.ts +5 -0
  311. package/src/lib/browser/page-review-contracts.ts +6 -1
  312. package/src/lib/browser/page-review-judge.ts +58 -13
  313. package/src/lib/browser/page-review-pack.ts +13 -2
  314. package/src/lib/browser/qa-run-contracts.ts +30 -4
  315. package/src/lib/browser/qa-run.ts +78 -17
  316. package/src/lib/browser/runts.ts +85 -37
  317. package/src/lib/browser/windows-chrome.ts +50 -0
  318. package/src/lib/browser/windows-login-sync.ts +275 -0
  319. package/src/lib/cookies/client.ts +62 -10
  320. package/src/lib/cookies/index.ts +3 -0
  321. package/src/lib/docs-metadata-sync.ts +44 -2
  322. package/src/lib/durable-job.ts +15 -0
  323. package/src/lib/http/client.ts +63 -1
  324. package/src/lib/instructions/templates.ts +4 -4
  325. package/dist/core/agents/state/process-handle.d.ts +0 -26
  326. package/dist/core/agents/state/process-handle.d.ts.map +0 -1
  327. package/dist/core/agents/state/process-handle.js +0 -121
  328. package/dist/core/workflow/recovery-audit.d.ts +0 -30
  329. package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
  330. package/dist/core/workflow/recovery-audit.js +0 -116
  331. package/src/core/agents/state/process-handle.ts +0 -165
  332. package/src/core/workflow/recovery-audit.ts +0 -181
@@ -18,6 +18,9 @@
18
18
  // file; process death leaves a dead-PID file for the next participant to
19
19
  // prune.
20
20
  //
21
+ // Every ticket that ends (released, timed out, or pruned after its process
22
+ // died) appends one record to the admission history (admission-history.ts).
23
+ //
21
24
  // Toolkit tier: this module must not import src/core (layering check).
22
25
 
23
26
  import { randomUUID } from "node:crypto";
@@ -31,8 +34,17 @@ import {
31
34
  } from "node:fs";
32
35
  import { tmpdir } from "node:os";
33
36
  import { join } from "node:path";
37
+ import {
38
+ ADMISSION_HISTORY_SCHEMA_VERSION,
39
+ type AdmissionHistoryOutcome,
40
+ type AdmissionReleaseResult,
41
+ appendAdmissionHistory,
42
+ resolveAdmissionHistoryDir,
43
+ } from "./admission-history.ts";
34
44
  import { coordEnv } from "./env.ts";
35
45
 
46
+ export type { AdmissionReleaseResult } from "./admission-history.ts";
47
+
36
48
  export interface AdmissionConfig {
37
49
  /** Base directory holding one subdirectory per resource. */
38
50
  dir: string;
@@ -44,6 +56,11 @@ export interface AdmissionConfig {
44
56
  pollMs?: number;
45
57
  /** Entries older than this are pruned regardless of PID (default 6h). */
46
58
  ttlMs?: number;
59
+ /**
60
+ * Directory for the slot-use history. Omitted: resolved from the queue
61
+ * directory (see resolveAdmissionHistoryDir). null: record nothing.
62
+ */
63
+ historyDir?: string | null;
47
64
  }
48
65
 
49
66
  export interface AdmissionEntry {
@@ -52,6 +69,12 @@ export interface AdmissionEntry {
52
69
  created_at: string;
53
70
  /** Set on holders once admitted. */
54
71
  acquired_at?: string;
72
+ /** Working directory of the queueing process. */
73
+ cwd?: string;
74
+ /** Capacity the queueing process asked for. */
75
+ capacity?: number;
76
+ /** Set on holders that had to wait: the holder labels they first waited behind. */
77
+ waited_behind?: string[];
55
78
  }
56
79
 
57
80
  export interface AdmissionWaitInfo {
@@ -67,6 +90,8 @@ export interface AdmissionAcquireOptions {
67
90
  timeoutMs?: number;
68
91
  /** Progress callback, invoked at most once per poll while waiting. */
69
92
  onWait?: (info: AdmissionWaitInfo) => void;
93
+ /** Time the caller already spent deferring for memory pressure, for the history. */
94
+ pressureWaitMs?: number;
70
95
  }
71
96
 
72
97
  export interface AdmissionHandle {
@@ -74,8 +99,11 @@ export interface AdmissionHandle {
74
99
  entry: string;
75
100
  /** Milliseconds spent waiting in the queue. */
76
101
  waitedMs: number;
77
- /** Give the slot back. Safe to call more than once. */
78
- release: () => void;
102
+ /**
103
+ * Give the slot back and record the slot use in the history. Safe to call
104
+ * more than once; only the first call records.
105
+ */
106
+ release: (result?: AdmissionReleaseResult) => void;
79
107
  }
80
108
 
81
109
  export class AdmissionTimeoutError extends Error {
@@ -143,7 +171,13 @@ interface LiveEntry {
143
171
  /** List live entries in lexical (= enqueue) order, pruning dead-PID, expired,
144
172
  * and torn files as a side effect. Every participant prunes, so a crashed
145
173
  * holder's slot frees as soon as anyone else looks. */
146
- function listLive(dir: string, ttlMs: number): LiveEntry[] {
174
+ type PruneReason = Extract<AdmissionHistoryOutcome, "abandoned" | "expired">;
175
+
176
+ function listLive(
177
+ dir: string,
178
+ ttlMs: number,
179
+ onPrune?: (entry: AdmissionEntry, reason: PruneReason) => void,
180
+ ): LiveEntry[] {
147
181
  let names: string[];
148
182
  try {
149
183
  names = readdirSync(dir)
@@ -167,6 +201,15 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
167
201
  label: typeof record.label === "string" ? record.label : "",
168
202
  created_at: record.created_at,
169
203
  ...(typeof record.acquired_at === "string" ? { acquired_at: record.acquired_at } : {}),
204
+ ...(typeof record.cwd === "string" ? { cwd: record.cwd } : {}),
205
+ ...(typeof record.capacity === "number" ? { capacity: record.capacity } : {}),
206
+ ...(Array.isArray(record.waited_behind)
207
+ ? {
208
+ waited_behind: record.waited_behind.filter(
209
+ (label): label is string => typeof label === "string",
210
+ ),
211
+ }
212
+ : {}),
170
213
  };
171
214
  }
172
215
  }
@@ -188,12 +231,16 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
188
231
  continue;
189
232
  }
190
233
  const age = now - Date.parse(entry.created_at);
191
- if (!pidAlive(entry.pid) || Number.isNaN(age) || age > ttlMs) {
234
+ const dead = !pidAlive(entry.pid);
235
+ if (dead || Number.isNaN(age) || age > ttlMs) {
236
+ let pruned = false;
192
237
  try {
193
238
  unlinkSync(path);
239
+ pruned = true;
194
240
  } catch {
195
- // a peer pruned it first
241
+ // a peer pruned it first, and records it
196
242
  }
243
+ if (pruned) onPrune?.(entry, dead ? "abandoned" : "expired");
197
244
  continue;
198
245
  }
199
246
  live.push({ name, entry });
@@ -201,6 +248,42 @@ function listLive(dir: string, ttlMs: number): LiveEntry[] {
201
248
  return live;
202
249
  }
203
250
 
251
+ function historyDirFor(config: Pick<AdmissionConfig, "dir" | "historyDir">): string | null {
252
+ return config.historyDir !== undefined
253
+ ? config.historyDir
254
+ : resolveAdmissionHistoryDir(config.dir);
255
+ }
256
+
257
+ /** Record an entry a participant pruned because its process died or it expired. */
258
+ function recordPruned(
259
+ historyDir: string | null,
260
+ resource: string,
261
+ entry: AdmissionEntry,
262
+ reason: PruneReason,
263
+ ): void {
264
+ if (!historyDir) return;
265
+ const now = Date.now();
266
+ const enqueuedMs = Date.parse(entry.created_at);
267
+ const acquiredMs = entry.acquired_at ? Date.parse(entry.acquired_at) : Number.NaN;
268
+ appendAdmissionHistory(historyDir, {
269
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
270
+ resource,
271
+ label: entry.label,
272
+ pid: entry.pid,
273
+ ...(entry.cwd ? { cwd: entry.cwd } : {}),
274
+ ...(entry.capacity !== undefined ? { capacity: entry.capacity } : {}),
275
+ outcome: reason,
276
+ enqueued_at: entry.created_at,
277
+ ...(entry.acquired_at ? { acquired_at: entry.acquired_at } : {}),
278
+ ended_at: new Date(now).toISOString(),
279
+ wait_ms: Number.isNaN(enqueuedMs)
280
+ ? 0
281
+ : Math.max(0, (Number.isNaN(acquiredMs) ? now : acquiredMs) - enqueuedMs),
282
+ ...(Number.isNaN(acquiredMs) ? {} : { hold_ms: Math.max(0, now - acquiredMs) }),
283
+ ...(entry.waited_behind?.length ? { waited_behind: entry.waited_behind } : {}),
284
+ });
285
+ }
286
+
204
287
  function sleep(ms: number): Promise<void> {
205
288
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
206
289
  }
@@ -224,11 +307,18 @@ export async function acquireAdmission(
224
307
  const tickets = ticketsDir(config);
225
308
  const held = heldDir(config);
226
309
 
310
+ const historyDir = historyDirFor(config);
311
+ const onPrune = (entry: AdmissionEntry, reason: PruneReason): void =>
312
+ recordPruned(historyDir, config.resource, entry, reason);
227
313
  const myEntry: AdmissionEntry = {
228
314
  pid: process.pid,
229
315
  label: options.label,
230
316
  created_at: new Date().toISOString(),
317
+ cwd: process.cwd(),
318
+ capacity: config.capacity,
231
319
  };
320
+ let waitedBehind: string[] | undefined;
321
+ const pressureWait = options.pressureWaitMs ? { pressure_wait_ms: options.pressureWaitMs } : {};
232
322
  const myName = `${String(Date.now()).padStart(13, "0")}-${process.pid}-${randomUUID().slice(0, 8)}.json`;
233
323
  writeEntry(tickets, myName, myEntry);
234
324
 
@@ -236,8 +326,8 @@ export async function acquireAdmission(
236
326
  const deadline = startedWaiting + timeoutMs;
237
327
  try {
238
328
  while (true) {
239
- const liveHeld = listLive(held, ttlMs);
240
- const liveTickets = listLive(tickets, ttlMs);
329
+ const liveHeld = listLive(held, ttlMs, onPrune);
330
+ const liveTickets = listLive(tickets, ttlMs, onPrune);
241
331
  const myIndex = liveTickets.findIndex((ticket) => ticket.name === myName);
242
332
  if (myIndex === -1) {
243
333
  // Pruned by a peer (should not happen while we are alive) or lost to
@@ -253,7 +343,7 @@ export async function acquireAdmission(
253
343
  // between two waiters' listings. Lexical order (= enqueue order)
254
344
  // decides who stays; the loser rejoins the queue with its original
255
345
  // priority because the filename keeps its enqueue timestamp.
256
- const heldNow = listLive(held, ttlMs);
346
+ const heldNow = listLive(held, ttlMs, onPrune);
257
347
  if (heldNow.length > config.capacity) {
258
348
  const keepers = new Set(
259
349
  heldNow.slice(0, config.capacity).map((holderEntry) => holderEntry.name),
@@ -264,12 +354,14 @@ export async function acquireAdmission(
264
354
  continue;
265
355
  }
266
356
  }
267
- writeEntry(held, myName, { ...myEntry, acquired_at: new Date().toISOString() });
357
+ const acquiredAt = new Date();
358
+ writeEntry(held, myName, { ...myEntry, acquired_at: acquiredAt.toISOString() });
359
+ const waitedMs = acquiredAt.getTime() - startedWaiting;
268
360
  let released = false;
269
361
  return {
270
362
  entry: myName,
271
- waitedMs: Date.now() - startedWaiting,
272
- release: () => {
363
+ waitedMs,
364
+ release: (result?: AdmissionReleaseResult) => {
273
365
  if (released) return;
274
366
  released = true;
275
367
  try {
@@ -277,14 +369,55 @@ export async function acquireAdmission(
277
369
  } catch {
278
370
  // already pruned
279
371
  }
372
+ const endedAt = new Date();
373
+ appendAdmissionHistory(historyDir, {
374
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
375
+ resource: config.resource,
376
+ label: myEntry.label,
377
+ pid: myEntry.pid,
378
+ ...(myEntry.cwd ? { cwd: myEntry.cwd } : {}),
379
+ capacity: config.capacity,
380
+ outcome: "released",
381
+ enqueued_at: myEntry.created_at,
382
+ acquired_at: acquiredAt.toISOString(),
383
+ ended_at: endedAt.toISOString(),
384
+ wait_ms: waitedMs,
385
+ hold_ms: endedAt.getTime() - acquiredAt.getTime(),
386
+ ...(waitedBehind?.length ? { waited_behind: waitedBehind } : {}),
387
+ ...pressureWait,
388
+ ...(result?.exitCode !== undefined ? { exit_code: result.exitCode } : {}),
389
+ ...(result?.signal !== undefined ? { signal: result.signal } : {}),
390
+ });
280
391
  },
281
392
  };
282
393
  }
394
+ if (waitedBehind === undefined) {
395
+ // Persist who this ticket waits behind, so the record survives even
396
+ // if this process dies in the queue and a peer prunes the ticket.
397
+ waitedBehind = liveHeld.map((holderEntry) => holderEntry.entry.label);
398
+ myEntry.waited_behind = waitedBehind;
399
+ writeEntry(tickets, myName, myEntry);
400
+ }
283
401
  options.onWait?.({
284
402
  position: myIndex + 1,
285
403
  holders: liveHeld.map((holderEntry) => holderEntry.entry),
286
404
  });
287
405
  if (Date.now() >= deadline) {
406
+ const endedAt = new Date();
407
+ appendAdmissionHistory(historyDir, {
408
+ v: ADMISSION_HISTORY_SCHEMA_VERSION,
409
+ resource: config.resource,
410
+ label: myEntry.label,
411
+ pid: myEntry.pid,
412
+ ...(myEntry.cwd ? { cwd: myEntry.cwd } : {}),
413
+ capacity: config.capacity,
414
+ outcome: "timeout",
415
+ enqueued_at: myEntry.created_at,
416
+ ended_at: endedAt.toISOString(),
417
+ wait_ms: endedAt.getTime() - startedWaiting,
418
+ ...(waitedBehind?.length ? { waited_behind: waitedBehind } : {}),
419
+ ...pressureWait,
420
+ });
288
421
  throw new AdmissionTimeoutError(
289
422
  config.resource,
290
423
  timeoutMs,
@@ -313,25 +446,90 @@ export interface AdmissionStatus {
313
446
 
314
447
  /** Snapshot the queue for one resource, pruning dead entries on the way. */
315
448
  export function admissionStatus(
316
- config: Pick<AdmissionConfig, "dir" | "resource" | "ttlMs">,
449
+ config: Pick<AdmissionConfig, "dir" | "resource" | "ttlMs" | "historyDir">,
317
450
  ): AdmissionStatus {
318
451
  const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
319
452
  const full: AdmissionConfig = { ...config, capacity: 1 };
453
+ const historyDir = historyDirFor(config);
454
+ const onPrune = (entry: AdmissionEntry, reason: PruneReason): void =>
455
+ recordPruned(historyDir, config.resource, entry, reason);
320
456
  return {
321
457
  resource: config.resource,
322
- holders: listLive(heldDir(full), ttlMs).map((holderEntry) => holderEntry.entry),
323
- waiters: listLive(ticketsDir(full), ttlMs).map((ticket) => ticket.entry),
458
+ holders: listLive(heldDir(full), ttlMs, onPrune).map((holderEntry) => holderEntry.entry),
459
+ waiters: listLive(ticketsDir(full), ttlMs, onPrune).map((ticket) => ticket.entry),
324
460
  };
325
461
  }
326
462
 
327
463
  /**
328
- * Base directory for admission queue state. tmpdir clears on reboot, which
329
- * is correct — stale queue state must not outlive the boot. Override with
330
- * HARNERY_ADMISSION_DIR. Host CLIs and harn commands must share this helper
331
- * so they join the same machine-wide queues.
464
+ * Base directory for admission queue state. The temp directory clears on
465
+ * reboot, which is correct: stale queue state must not outlive the boot.
466
+ * Override with HARNERY_ADMISSION_DIR. Host CLIs and harn commands must share
467
+ * this helper so they join the same machine-wide queues.
468
+ *
469
+ * On POSIX the default is the literal /tmp, not os.tmpdir(): agent sandboxes
470
+ * often set a private TMPDIR per session, and a queue under it would admit
471
+ * every session on its own.
332
472
  */
333
473
  export function admissionBaseDir(): string {
334
- return coordEnv("ADMISSION_DIR") ?? join(tmpdir(), "harnery-admission");
474
+ return (
475
+ coordEnv("ADMISSION_DIR") ??
476
+ (process.platform === "win32" ? join(tmpdir(), "harnery-admission") : "/tmp/harnery-admission")
477
+ );
478
+ }
479
+
480
+ /**
481
+ * Linux memory pressure: the `full avg10` figure from /proc/pressure/memory,
482
+ * the share of the last ten seconds in which every runnable task stalled on
483
+ * memory. Undefined where pressure stall information is unavailable.
484
+ */
485
+ export function memoryPressureFullAvg10(): number | undefined {
486
+ try {
487
+ const line = readFileSync("/proc/pressure/memory", "utf8")
488
+ .split("\n")
489
+ .find((candidate) => candidate.startsWith("full"));
490
+ const match = line ? /avg10=([0-9.]+)/.exec(line) : null;
491
+ return match?.[1] !== undefined ? Number(match[1]) : undefined;
492
+ } catch {
493
+ return undefined;
494
+ }
495
+ }
496
+
497
+ export interface PressureWaitOptions {
498
+ /** Defer while `full avg10` is above this value. */
499
+ threshold: number;
500
+ /** Give up deferring after this long and let the caller start anyway. */
501
+ maxWaitMs: number;
502
+ pollMs?: number;
503
+ /** Pressure reader; tests inject one. */
504
+ read?: () => number | undefined;
505
+ /** Called once when deferral starts and once if it gives up. */
506
+ onWait?: (event: { pressure: number; waitedMs: number; gaveUp: boolean }) => void;
507
+ }
508
+
509
+ /**
510
+ * Wait, holding no slot, while the machine is already stalling on memory, so
511
+ * a heavy job does not start into a storm. Never blocks forever: after
512
+ * maxWaitMs it returns and the caller starts anyway. Returns the time waited.
513
+ */
514
+ export async function waitForMemoryPressure(options: PressureWaitOptions): Promise<number> {
515
+ const read = options.read ?? memoryPressureFullAvg10;
516
+ const pollMs = options.pollMs ?? 5_000;
517
+ const started = Date.now();
518
+ let announced = false;
519
+ while (true) {
520
+ const pressure = read();
521
+ if (pressure === undefined || pressure <= options.threshold) return Date.now() - started;
522
+ const waitedMs = Date.now() - started;
523
+ if (waitedMs + pollMs > options.maxWaitMs) {
524
+ options.onWait?.({ pressure, waitedMs, gaveUp: true });
525
+ return waitedMs;
526
+ }
527
+ if (!announced) {
528
+ announced = true;
529
+ options.onWait?.({ pressure, waitedMs, gaveUp: false });
530
+ }
531
+ await sleep(pollMs);
532
+ }
335
533
  }
336
534
 
337
535
  /** Resources present under an admission base directory. */
@@ -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));