harnery 0.31.6 → 0.33.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 (264) hide show
  1. package/README.md +2 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +5 -1
  4. package/dist/commands/agents.d.ts +0 -9
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +280 -94
  7. package/dist/commands/browse-session.d.ts +21 -0
  8. package/dist/commands/browse-session.d.ts.map +1 -0
  9. package/dist/commands/browse-session.js +157 -0
  10. package/dist/commands/browse.d.ts.map +1 -1
  11. package/dist/commands/browse.js +205 -37
  12. package/dist/commands/checkpoint.js +1 -1
  13. package/dist/commands/claude-desktop.d.ts +2 -2
  14. package/dist/commands/claude-desktop.d.ts.map +1 -1
  15. package/dist/commands/claude-desktop.js +74 -3
  16. package/dist/commands/deinit.d.ts.map +1 -1
  17. package/dist/commands/deinit.js +4 -0
  18. package/dist/commands/docs.d.ts.map +1 -1
  19. package/dist/commands/docs.js +40 -0
  20. package/dist/commands/doctor.d.ts +4 -2
  21. package/dist/commands/doctor.d.ts.map +1 -1
  22. package/dist/commands/doctor.js +141 -19
  23. package/dist/commands/init.d.ts +2 -2
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +26 -5
  26. package/dist/commands/instructions.d.ts +4 -0
  27. package/dist/commands/instructions.d.ts.map +1 -0
  28. package/dist/commands/instructions.js +100 -0
  29. package/dist/commands/presence.d.ts.map +1 -1
  30. package/dist/commands/presence.js +4 -1
  31. package/dist/commands/tunnel.d.ts.map +1 -1
  32. package/dist/commands/tunnel.js +2 -3
  33. package/dist/core/agents/canonical-emit.d.ts +2 -3
  34. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  35. package/dist/core/agents/canonical-emit.js +6 -5
  36. package/dist/core/agents/cli.js +128 -89
  37. package/dist/core/agents/coord-client.d.ts +18 -3
  38. package/dist/core/agents/coord-client.d.ts.map +1 -1
  39. package/dist/core/agents/coord-client.js +5 -4
  40. package/dist/core/agents/finalization.d.ts +68 -0
  41. package/dist/core/agents/finalization.d.ts.map +1 -0
  42. package/dist/core/agents/finalization.js +443 -0
  43. package/dist/core/agents/git-hook.d.ts +51 -0
  44. package/dist/core/agents/git-hook.d.ts.map +1 -0
  45. package/dist/core/agents/git-hook.js +118 -0
  46. package/dist/core/agents/index.d.ts +1 -0
  47. package/dist/core/agents/index.d.ts.map +1 -1
  48. package/dist/core/agents/index.js +1 -0
  49. package/dist/core/agents/render/prompt-context.d.ts +12 -5
  50. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  51. package/dist/core/agents/render/prompt-context.js +70 -16
  52. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  53. package/dist/core/agents/render/session-context.js +23 -4
  54. package/dist/core/agents/rules/claim-conflict.js +3 -3
  55. package/dist/core/agents/rules/commit-conflict.d.ts +15 -6
  56. package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
  57. package/dist/core/agents/rules/commit-conflict.js +21 -5
  58. package/dist/core/agents/rules/stop-hook.d.ts +3 -0
  59. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  60. package/dist/core/agents/rules/stop-hook.js +59 -23
  61. package/dist/core/agents/session-events.d.ts +8 -16
  62. package/dist/core/agents/session-events.d.ts.map +1 -1
  63. package/dist/core/agents/session-events.js +12 -26
  64. package/dist/core/agents/state/heartbeat-projector.d.ts +14 -0
  65. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  66. package/dist/core/agents/state/heartbeat-projector.js +98 -1
  67. package/dist/core/agents/state/heartbeat-writer.d.ts +47 -5
  68. package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
  69. package/dist/core/agents/state/heartbeat-writer.js +86 -17
  70. package/dist/core/agents/state/names.d.ts +22 -1
  71. package/dist/core/agents/state/names.d.ts.map +1 -1
  72. package/dist/core/agents/state/names.js +40 -2
  73. package/dist/core/agents/state/session-state.d.ts +44 -0
  74. package/dist/core/agents/state/session-state.d.ts.map +1 -0
  75. package/dist/core/agents/state/session-state.js +81 -0
  76. package/dist/core/config.d.ts +30 -2
  77. package/dist/core/config.d.ts.map +1 -1
  78. package/dist/core/config.js +74 -11
  79. package/dist/core/governor/planning.d.ts.map +1 -1
  80. package/dist/core/governor/planning.js +12 -13
  81. package/dist/core/hooks/adapter/detect.d.ts +2 -4
  82. package/dist/core/hooks/adapter/detect.d.ts.map +1 -1
  83. package/dist/core/hooks/adapter/detect.js +4 -14
  84. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  85. package/dist/core/hooks/adapter/events.js +1 -0
  86. package/dist/core/hooks/adapter/output.d.ts +1 -1
  87. package/dist/core/hooks/adapter/output.d.ts.map +1 -1
  88. package/dist/core/hooks/adapter/output.js +5 -4
  89. package/dist/core/hooks/adapter/parse.d.ts +1 -1
  90. package/dist/core/hooks/adapter/parse.d.ts.map +1 -1
  91. package/dist/core/hooks/adapter/parse.js +2 -0
  92. package/dist/core/hooks/adapter/wiring.d.ts +23 -0
  93. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  94. package/dist/core/hooks/adapter/wiring.js +32 -0
  95. package/dist/core/hooks/cli.d.ts +1 -2
  96. package/dist/core/hooks/cli.d.ts.map +1 -1
  97. package/dist/core/hooks/cli.js +258 -32
  98. package/dist/core/hooks/codex-authorization.d.ts +30 -0
  99. package/dist/core/hooks/codex-authorization.d.ts.map +1 -0
  100. package/dist/core/hooks/codex-authorization.js +199 -0
  101. package/dist/core/hooks/codex-wsl-bridge.d.ts +46 -0
  102. package/dist/core/hooks/codex-wsl-bridge.d.ts.map +1 -0
  103. package/dist/core/hooks/codex-wsl-bridge.js +137 -0
  104. package/dist/core/hooks/effects/index.d.ts +1 -1
  105. package/dist/core/hooks/effects/index.js +3 -3
  106. package/dist/core/hooks/events/schema.d.ts +57 -1
  107. package/dist/core/hooks/events/schema.d.ts.map +1 -1
  108. package/dist/core/hooks/resolve/owner.d.ts +7 -1
  109. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  110. package/dist/core/hooks/resolve/owner.js +15 -0
  111. package/dist/core/hooks/resolve/transcript.d.ts +44 -0
  112. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  113. package/dist/core/hooks/resolve/transcript.js +176 -1
  114. package/dist/core/hooks/session-name-presence.d.ts +30 -0
  115. package/dist/core/hooks/session-name-presence.d.ts.map +1 -0
  116. package/dist/core/hooks/session-name-presence.js +43 -0
  117. package/dist/core/hooks/unsafe-cross-shell.d.ts +13 -0
  118. package/dist/core/hooks/unsafe-cross-shell.d.ts.map +1 -0
  119. package/dist/core/hooks/unsafe-cross-shell.js +151 -0
  120. package/dist/core/presence/blob.d.ts +4 -0
  121. package/dist/core/presence/blob.d.ts.map +1 -1
  122. package/dist/core/presence/blob.js +12 -0
  123. package/dist/core/presence/index.js +15 -1
  124. package/dist/core/work/state.d.ts +4 -5
  125. package/dist/core/work/state.d.ts.map +1 -1
  126. package/dist/core/work/state.js +5 -8
  127. package/dist/core/workflow/index.d.ts +1 -1
  128. package/dist/core/workflow/index.d.ts.map +1 -1
  129. package/dist/core/workflow/proof.d.ts +2 -2
  130. package/dist/core/workflow/proof.d.ts.map +1 -1
  131. package/dist/core/workflow/proof.js +2 -2
  132. package/dist/core/workflow/run-state.d.ts +2 -2
  133. package/dist/core/workflow/run-state.d.ts.map +1 -1
  134. package/dist/core/workflow/run-state.js +2 -2
  135. package/dist/core/workflow/types.d.ts +3 -3
  136. package/dist/core/workflow/types.d.ts.map +1 -1
  137. package/dist/core/workflow/workspaces/execution.d.ts +2 -2
  138. package/dist/core/workflow/workspaces/execution.d.ts.map +1 -1
  139. package/dist/core/workflow/workspaces/execution.js +5 -0
  140. package/dist/core/workflow/workspaces/index.d.ts +1 -1
  141. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  142. package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
  143. package/dist/core/workflow/workspaces/local-git.js +1 -2
  144. package/dist/core/workflow/workspaces/paths.d.ts +2 -2
  145. package/dist/core/workflow/workspaces/types.d.ts +0 -6
  146. package/dist/core/workflow/workspaces/types.d.ts.map +1 -1
  147. package/dist/core/workflow/workspaces/validate.d.ts +0 -2
  148. package/dist/core/workflow/workspaces/validate.d.ts.map +1 -1
  149. package/dist/core/workflow/workspaces/validate.js +0 -2
  150. package/dist/lib/browser/client.d.ts +118 -1
  151. package/dist/lib/browser/client.d.ts.map +1 -1
  152. package/dist/lib/browser/client.js +435 -6
  153. package/dist/lib/browser/geometry.d.ts.map +1 -1
  154. package/dist/lib/browser/geometry.js +180 -37
  155. package/dist/lib/browser/index.d.ts +5 -1
  156. package/dist/lib/browser/index.d.ts.map +1 -1
  157. package/dist/lib/browser/index.js +5 -1
  158. package/dist/lib/browser/netscape-cookies.d.ts +6 -0
  159. package/dist/lib/browser/netscape-cookies.d.ts.map +1 -0
  160. package/dist/lib/browser/netscape-cookies.js +31 -0
  161. package/dist/lib/browser/proxy.d.ts +24 -0
  162. package/dist/lib/browser/proxy.d.ts.map +1 -0
  163. package/dist/lib/browser/proxy.js +84 -0
  164. package/dist/lib/browser/runts.d.ts +6 -0
  165. package/dist/lib/browser/runts.d.ts.map +1 -1
  166. package/dist/lib/browser/runts.js +21 -4
  167. package/dist/lib/browser/session-control.d.ts +112 -0
  168. package/dist/lib/browser/session-control.d.ts.map +1 -0
  169. package/dist/lib/browser/session-control.js +670 -0
  170. package/dist/lib/claude-desktop.d.ts +47 -0
  171. package/dist/lib/claude-desktop.d.ts.map +1 -1
  172. package/dist/lib/claude-desktop.js +169 -1
  173. package/dist/lib/docs-links.d.ts +108 -0
  174. package/dist/lib/docs-links.d.ts.map +1 -0
  175. package/dist/lib/docs-links.js +555 -0
  176. package/dist/lib/exec.d.ts +1 -1
  177. package/dist/lib/exec.js +1 -1
  178. package/dist/lib/identities/assume.d.ts +4 -2
  179. package/dist/lib/identities/assume.d.ts.map +1 -1
  180. package/dist/lib/identities/assume.js +15 -2
  181. package/dist/lib/instructions/bundle.d.ts +33 -0
  182. package/dist/lib/instructions/bundle.d.ts.map +1 -0
  183. package/dist/lib/instructions/bundle.js +149 -0
  184. package/dist/lib/instructions/git-hooks.d.ts +75 -0
  185. package/dist/lib/instructions/git-hooks.d.ts.map +1 -0
  186. package/dist/lib/instructions/git-hooks.js +238 -0
  187. package/dist/lib/instructions/splice.d.ts +10 -4
  188. package/dist/lib/instructions/splice.d.ts.map +1 -1
  189. package/dist/lib/instructions/splice.js +19 -11
  190. package/package.json +2 -2
  191. package/schemas/config.schema.json +32 -0
  192. package/src/commander.ts +5 -1
  193. package/src/commands/agents.ts +341 -113
  194. package/src/commands/browse-session.ts +245 -0
  195. package/src/commands/browse.ts +287 -44
  196. package/src/commands/checkpoint.ts +1 -1
  197. package/src/commands/claude-desktop.ts +106 -5
  198. package/src/commands/deinit.ts +7 -1
  199. package/src/commands/docs.ts +56 -0
  200. package/src/commands/doctor.ts +155 -21
  201. package/src/commands/init.ts +28 -9
  202. package/src/commands/instructions.ts +129 -0
  203. package/src/commands/presence.ts +7 -1
  204. package/src/commands/tunnel.ts +2 -5
  205. package/src/core/agents/canonical-emit.ts +7 -6
  206. package/src/core/agents/cli.ts +143 -89
  207. package/src/core/agents/coord-client.ts +21 -5
  208. package/src/core/agents/finalization.ts +595 -0
  209. package/src/core/agents/git-hook.ts +126 -0
  210. package/src/core/agents/index.ts +1 -0
  211. package/src/core/agents/render/prompt-context.ts +96 -16
  212. package/src/core/agents/render/session-context.ts +29 -4
  213. package/src/core/agents/rules/claim-conflict.ts +3 -3
  214. package/src/core/agents/rules/commit-conflict.ts +35 -8
  215. package/src/core/agents/rules/stop-hook.ts +71 -23
  216. package/src/core/agents/session-events.ts +14 -41
  217. package/src/core/agents/state/heartbeat-projector.ts +108 -1
  218. package/src/core/agents/state/heartbeat-writer.ts +129 -16
  219. package/src/core/agents/state/names.ts +54 -2
  220. package/src/core/agents/state/session-state.ts +131 -0
  221. package/src/core/config.ts +92 -12
  222. package/src/core/governor/planning.ts +17 -12
  223. package/src/core/hooks/adapter/detect.ts +4 -13
  224. package/src/core/hooks/adapter/events.ts +1 -0
  225. package/src/core/hooks/adapter/output.ts +9 -4
  226. package/src/core/hooks/adapter/parse.ts +3 -0
  227. package/src/core/hooks/adapter/wiring.ts +43 -0
  228. package/src/core/hooks/cli.ts +296 -27
  229. package/src/core/hooks/codex-authorization.ts +253 -0
  230. package/src/core/hooks/codex-wsl-bridge.ts +188 -0
  231. package/src/core/hooks/effects/index.ts +5 -5
  232. package/src/core/hooks/events/schema.ts +71 -0
  233. package/src/core/hooks/resolve/owner.ts +22 -1
  234. package/src/core/hooks/resolve/transcript.ts +164 -1
  235. package/src/core/hooks/session-name-presence.ts +52 -0
  236. package/src/core/hooks/unsafe-cross-shell.ts +160 -0
  237. package/src/core/presence/blob.ts +18 -0
  238. package/src/core/presence/index.ts +18 -1
  239. package/src/core/work/state.ts +8 -12
  240. package/src/core/workflow/engine.ts +2 -2
  241. package/src/core/workflow/index.ts +0 -1
  242. package/src/core/workflow/proof.ts +4 -4
  243. package/src/core/workflow/run-state.ts +5 -5
  244. package/src/core/workflow/types.ts +3 -3
  245. package/src/core/workflow/workspaces/execution.ts +11 -4
  246. package/src/core/workflow/workspaces/index.ts +0 -3
  247. package/src/core/workflow/workspaces/local-git.ts +1 -2
  248. package/src/core/workflow/workspaces/paths.ts +2 -2
  249. package/src/core/workflow/workspaces/types.ts +0 -9
  250. package/src/core/workflow/workspaces/validate.ts +1 -5
  251. package/src/lib/browser/client.ts +528 -6
  252. package/src/lib/browser/geometry.ts +197 -37
  253. package/src/lib/browser/index.ts +39 -0
  254. package/src/lib/browser/netscape-cookies.ts +39 -0
  255. package/src/lib/browser/proxy.ts +105 -0
  256. package/src/lib/browser/runts.ts +27 -3
  257. package/src/lib/browser/session-control.ts +892 -0
  258. package/src/lib/claude-desktop.ts +232 -1
  259. package/src/lib/docs-links.ts +674 -0
  260. package/src/lib/exec.ts +1 -1
  261. package/src/lib/identities/assume.ts +22 -1
  262. package/src/lib/instructions/bundle.ts +199 -0
  263. package/src/lib/instructions/git-hooks.ts +259 -0
  264. package/src/lib/instructions/splice.ts +41 -11
@@ -1,11 +1,12 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { chmodSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { resolve } from "node:path";
3
+ import { dirname, resolve } from "node:path";
4
4
  import axe from "axe-core";
5
5
  import {
6
6
  type BrowserContext,
7
7
  type ConsoleMessage,
8
8
  chromium,
9
+ type Locator,
9
10
  type Page,
10
11
  type Cookie as PWCookie,
11
12
  type Request,
@@ -112,6 +113,8 @@ export interface BrowserOptions {
112
113
  * defaults only.
113
114
  */
114
115
  launchArgs?: string[];
116
+ /** Authenticated browser proxy passed directly to Playwright. */
117
+ proxy?: { server: string; username?: string; password?: string };
115
118
  }
116
119
 
117
120
  export interface NavigateResult {
@@ -154,6 +157,83 @@ export interface Diagnostics {
154
157
  viewport: { width: number; height: number } | null;
155
158
  }
156
159
 
160
+ export type BrowserSessionLocator =
161
+ | { kind: "selector"; value: string; partial: boolean }
162
+ | { kind: "role"; value: string; name?: string; partial: boolean }
163
+ | { kind: "label"; value: string; partial: boolean }
164
+ | { kind: "text"; value: string; partial: boolean };
165
+
166
+ export interface BrowserSessionStatus {
167
+ phase: "ready";
168
+ active_tab: number;
169
+ tab_count: number;
170
+ revision: number;
171
+ navigation: BrowserSessionNavigation;
172
+ }
173
+
174
+ export type BrowserSessionNavigationType =
175
+ | "navigate"
176
+ | "reload"
177
+ | "back_forward"
178
+ | "prerender"
179
+ | "unknown";
180
+
181
+ export interface BrowserSessionNavigation {
182
+ /** Monotonic count of committed documents observed in this tab. */
183
+ sequence: number;
184
+ /** UTC time when the latest document reached DOMContentLoaded. */
185
+ occurred_at: string | null;
186
+ /** URL committed by the latest observed document navigation. */
187
+ url: string;
188
+ /** Browser Navigation Timing classification for the current document. */
189
+ type: BrowserSessionNavigationType;
190
+ }
191
+
192
+ export interface BrowserSessionTab {
193
+ index: number;
194
+ title: string;
195
+ url: string;
196
+ active: boolean;
197
+ revision: number;
198
+ navigation: BrowserSessionNavigation;
199
+ }
200
+
201
+ export interface BrowserSessionControl {
202
+ kind: string;
203
+ name: string;
204
+ attributes: Record<string, string | boolean>;
205
+ has_value?: boolean;
206
+ }
207
+
208
+ export interface BrowserSessionInspection {
209
+ active_tab: number;
210
+ url: string;
211
+ title: string;
212
+ text: string;
213
+ controls: BrowserSessionControl[];
214
+ focus: BrowserSessionControl | null;
215
+ revision: number;
216
+ navigation: BrowserSessionNavigation;
217
+ truncated: boolean;
218
+ }
219
+
220
+ export interface BrowserSessionScreenshot {
221
+ path: string;
222
+ width: number;
223
+ height: number;
224
+ revision: number;
225
+ }
226
+
227
+ export class BrowserSessionActionError extends Error {
228
+ constructor(
229
+ readonly code: string,
230
+ message: string,
231
+ ) {
232
+ super(message);
233
+ this.name = "BrowserSessionActionError";
234
+ }
235
+ }
236
+
157
237
  const DEFAULT_PROFILE = resolve(homedir(), ".cache", "harnery", "browser-profile");
158
238
 
159
239
  // Bound how many Chromium processes SPAWN at the same moment. When many Browser
@@ -162,10 +242,12 @@ const DEFAULT_PROFILE = resolve(homedir(), ".cache", "harnery", "browser-profile
162
242
  // resources and Chromium launch dies with an unhandled ENOENT that a per-call
163
243
  // retry can't catch. Serializing only the brief launch phase (never the
164
244
  // browser's lifetime, so N browsers still run concurrently) removes the burst
165
- // that causes it. Overridable via HARNERY_MAX_BROWSER_LAUNCHES.
245
+ // that causes it. The default stays at one because even a three-process burst
246
+ // can exhaust Bun/Playwright's IPC sockets under a full suite; callers with
247
+ // more headroom can opt into a higher value via HARNERY_MAX_BROWSER_LAUNCHES.
166
248
  const MAX_CONCURRENT_LAUNCHES = Math.max(
167
249
  1,
168
- Number.parseInt(process.env.HARNERY_MAX_BROWSER_LAUNCHES ?? "3", 10) || 3,
250
+ Number.parseInt(process.env.HARNERY_MAX_BROWSER_LAUNCHES ?? "1", 10) || 1,
169
251
  );
170
252
  let activeLaunches = 0;
171
253
  const launchWaiters: Array<() => void> = [];
@@ -191,6 +273,12 @@ function releaseLaunchSlot(): void {
191
273
  export class Browser {
192
274
  private context: BrowserContext | null = null;
193
275
  private page: Page | null = null;
276
+ private pageIndexes = new Map<Page, number>();
277
+ private pageRecency = new Map<Page, number>();
278
+ private pageNavigations = new Map<Page, BrowserSessionNavigation>();
279
+ private nextPageIndex = 0;
280
+ private recencyClock = 0;
281
+ private sessionRevision = 0;
194
282
  readonly profileDir: string;
195
283
  private consoleEvents: ConsoleEvent[] = [];
196
284
  private pageErrors: PageErrorEvent[] = [];
@@ -202,6 +290,7 @@ export class Browser {
202
290
 
203
291
  /** Lazy: caller-side helper to find the active page if mid-flow. */
204
292
  get currentPage(): Page {
293
+ if (!this.page || this.page.isClosed()) this.selectMostRecentPage();
205
294
  if (!this.page) {
206
295
  throw new Error("Browser not opened. Call open() first.");
207
296
  }
@@ -223,6 +312,7 @@ export class Browser {
223
312
  ...(this.opts.launchArgs && this.opts.launchArgs.length > 0
224
313
  ? { args: this.opts.launchArgs }
225
314
  : {}),
315
+ ...(this.opts.proxy ? { proxy: this.opts.proxy } : {}),
226
316
  ...(this.opts.recordHarPath
227
317
  ? { recordHar: { path: this.opts.recordHarPath, mode: "full" as const } }
228
318
  : {}),
@@ -253,8 +343,9 @@ export class Browser {
253
343
  }
254
344
 
255
345
  const pages = this.context.pages();
256
- this.page = pages[0] ?? (await this.context.newPage());
257
- this.attachDiagnosticListeners(this.page);
346
+ const firstPage = pages[0] ?? (await this.context.newPage());
347
+ for (const page of this.context.pages()) this.trackPage(page, page === firstPage);
348
+ this.context.on("page", (page) => this.trackPage(page, true));
258
349
  }
259
350
 
260
351
  /**
@@ -292,6 +383,10 @@ export class Browser {
292
383
  }
293
384
  this.context = null;
294
385
  this.page = null;
386
+ this.pageIndexes.clear();
387
+ this.pageRecency.clear();
388
+ this.nextPageIndex = 0;
389
+ this.recencyClock = 0;
295
390
  this.consoleEvents = [];
296
391
  this.pageErrors = [];
297
392
  this.failedRequests = [];
@@ -351,6 +446,80 @@ export class Browser {
351
446
  });
352
447
  }
353
448
 
449
+ private trackPage(page: Page, makeActive: boolean): void {
450
+ if (!this.pageIndexes.has(page)) {
451
+ this.pageIndexes.set(page, this.nextPageIndex++);
452
+ this.pageNavigations.set(page, {
453
+ sequence: 0,
454
+ occurred_at: null,
455
+ url: page.url(),
456
+ type: "unknown",
457
+ });
458
+ this.attachDiagnosticListeners(page);
459
+ page.on("domcontentloaded", () => this.recordDocumentNavigation(page));
460
+ page.once("close", () => {
461
+ this.pageRecency.delete(page);
462
+ this.pageNavigations.delete(page);
463
+ if (this.page === page) this.selectMostRecentPage();
464
+ });
465
+ }
466
+ if (makeActive) this.setActivePage(page);
467
+ }
468
+
469
+ private setActivePage(page: Page): void {
470
+ if (page.isClosed()) return;
471
+ this.page = page;
472
+ this.pageRecency.set(page, ++this.recencyClock);
473
+ }
474
+
475
+ private recordDocumentNavigation(page: Page): void {
476
+ if (page.isClosed()) return;
477
+ const previous = this.pageNavigations.get(page);
478
+ this.pageNavigations.set(page, {
479
+ sequence: (previous?.sequence ?? 0) + 1,
480
+ occurred_at: new Date().toISOString(),
481
+ url: page.url(),
482
+ type: "unknown",
483
+ });
484
+ }
485
+
486
+ private async describeNavigation(page: Page): Promise<BrowserSessionNavigation> {
487
+ const current = this.pageNavigations.get(page) ?? {
488
+ sequence: 0,
489
+ occurred_at: null,
490
+ url: page.url(),
491
+ type: "unknown" as const,
492
+ };
493
+ const type = await page
494
+ .evaluate<BrowserSessionNavigationType>(() => {
495
+ const entry = performance.getEntriesByType("navigation")[0] as
496
+ | PerformanceNavigationTiming
497
+ | undefined;
498
+ const observed = entry?.type;
499
+ return observed === "navigate" ||
500
+ observed === "reload" ||
501
+ observed === "back_forward" ||
502
+ observed === "prerender"
503
+ ? observed
504
+ : "unknown";
505
+ })
506
+ .catch(() => current.type);
507
+ const latest = this.pageNavigations.get(page);
508
+ if (latest?.sequence === current.sequence) {
509
+ const updated = { ...latest, type };
510
+ this.pageNavigations.set(page, updated);
511
+ return updated;
512
+ }
513
+ return latest ?? current;
514
+ }
515
+
516
+ private selectMostRecentPage(): void {
517
+ const surviving = [...this.pageIndexes.keys()].filter((page) => !page.isClosed());
518
+ surviving.sort((a, b) => (this.pageRecency.get(b) ?? 0) - (this.pageRecency.get(a) ?? 0));
519
+ this.page = surviving[0] ?? null;
520
+ if (this.page) this.pageRecency.set(this.page, ++this.recencyClock);
521
+ }
522
+
354
523
  /**
355
524
  * Snapshot of every event captured since `open()`. Returned objects are
356
525
  * copies, so callers can safely store them after `close()`.
@@ -661,6 +830,339 @@ export class Browser {
661
830
  });
662
831
  }
663
832
 
833
+ async sessionStatus(): Promise<BrowserSessionStatus> {
834
+ const tabs = await this.sessionTabs();
835
+ const active = tabs.find((tab) => tab.active);
836
+ if (!active) throw new BrowserSessionActionError("no_active_tab", "No active browser tab.");
837
+ return {
838
+ phase: "ready",
839
+ active_tab: active.index,
840
+ tab_count: tabs.length,
841
+ revision: this.sessionRevision,
842
+ navigation: active.navigation,
843
+ };
844
+ }
845
+
846
+ async sessionTabs(): Promise<BrowserSessionTab[]> {
847
+ this.selectMostRecentPageIfNeeded();
848
+ const pages = [...this.pageIndexes.entries()]
849
+ .filter(([page]) => !page.isClosed())
850
+ .sort((a, b) => a[1] - b[1]);
851
+ return await Promise.all(
852
+ pages.map(async ([page, index]) => ({
853
+ index,
854
+ title: await page.title().catch(() => ""),
855
+ url: page.url(),
856
+ active: page === this.page,
857
+ revision: this.sessionRevision,
858
+ navigation: await this.describeNavigation(page),
859
+ })),
860
+ );
861
+ }
862
+
863
+ async sessionSelectTab(index: number): Promise<BrowserSessionTab> {
864
+ const page = this.pageForIndex(index);
865
+ await page.bringToFront();
866
+ this.setActivePage(page);
867
+ this.sessionRevision++;
868
+ return this.describeTab(page);
869
+ }
870
+
871
+ async sessionOpenTab(url: string): Promise<BrowserSessionTab> {
872
+ if (!this.context) throw new BrowserSessionActionError("not_ready", "Browser is not open.");
873
+ const previous = this.page;
874
+ const page = await this.context.newPage();
875
+ this.trackPage(page, true);
876
+ try {
877
+ await page.goto(url, { waitUntil: this.opts.waitUntil ?? "load" });
878
+ this.sessionRevision++;
879
+ return await this.describeTab(page);
880
+ } catch (error) {
881
+ await page.close().catch(() => {});
882
+ if (previous && !previous.isClosed()) this.setActivePage(previous);
883
+ throw error;
884
+ }
885
+ }
886
+
887
+ async sessionCloseTab(index: number): Promise<BrowserSessionTab> {
888
+ const surviving = [...this.pageIndexes.keys()].filter((page) => !page.isClosed());
889
+ if (surviving.length <= 1) {
890
+ throw new BrowserSessionActionError("final_tab", "The final browser tab cannot be closed.");
891
+ }
892
+ const page = this.pageForIndex(index);
893
+ await page.close();
894
+ this.selectMostRecentPageIfNeeded();
895
+ this.sessionRevision++;
896
+ return this.describeTab(this.currentPage);
897
+ }
898
+
899
+ async sessionGoto(url: string): Promise<BrowserSessionTab> {
900
+ const page = this.currentPage;
901
+ await page.goto(url, { waitUntil: this.opts.waitUntil ?? "load" });
902
+ this.sessionRevision++;
903
+ return this.describeTab(page);
904
+ }
905
+
906
+ async sessionReload(): Promise<BrowserSessionTab> {
907
+ const page = this.currentPage;
908
+ await page.reload({ waitUntil: this.opts.waitUntil ?? "load" });
909
+ this.sessionRevision++;
910
+ return this.describeTab(page);
911
+ }
912
+
913
+ async sessionClick(locator: BrowserSessionLocator): Promise<{ revision: number }> {
914
+ const target = await this.strictSessionLocator(locator);
915
+ await target.click();
916
+ this.sessionRevision++;
917
+ return { revision: this.sessionRevision };
918
+ }
919
+
920
+ async sessionFill(locator: BrowserSessionLocator, value: string): Promise<{ revision: number }> {
921
+ const target = await this.strictSessionLocator(locator);
922
+ await target.fill(value);
923
+ this.sessionRevision++;
924
+ return { revision: this.sessionRevision };
925
+ }
926
+
927
+ async sessionPress(key: string): Promise<{ revision: number }> {
928
+ await this.currentPage.keyboard.press(key);
929
+ this.sessionRevision++;
930
+ return { revision: this.sessionRevision };
931
+ }
932
+
933
+ async sessionWait(locator: BrowserSessionLocator): Promise<{ revision: number }> {
934
+ const target = this.sessionLocator(locator);
935
+ await target.first().waitFor({ state: "visible" });
936
+ await this.requireStrictLocator(target);
937
+ this.sessionRevision++;
938
+ return { revision: this.sessionRevision };
939
+ }
940
+
941
+ async sessionInspect(locator?: BrowserSessionLocator): Promise<BrowserSessionInspection> {
942
+ const page = this.currentPage;
943
+ const tabIndex = this.pageIndexes.get(page);
944
+ if (tabIndex === undefined) {
945
+ throw new BrowserSessionActionError("no_active_tab", "No active browser tab.");
946
+ }
947
+ const rawText = locator
948
+ ? await (await this.strictSessionLocator(locator)).innerText()
949
+ : await page.locator("body").innerText();
950
+ const textResult = truncateUtf8(rawText, 256 * 1024);
951
+ const observed = await page.evaluate(() => {
952
+ const visible = (element: Element): boolean => {
953
+ const style = getComputedStyle(element);
954
+ const rect = element.getBoundingClientRect();
955
+ return (
956
+ style.display !== "none" &&
957
+ style.visibility !== "hidden" &&
958
+ Number(style.opacity) > 0 &&
959
+ rect.width > 0 &&
960
+ rect.height > 0
961
+ );
962
+ };
963
+ const named = (element: Element): string => {
964
+ const aria = element.getAttribute("aria-label")?.trim();
965
+ if (aria) return aria;
966
+ const labelledBy = element.getAttribute("aria-labelledby");
967
+ if (labelledBy) {
968
+ const text = labelledBy
969
+ .split(/\s+/)
970
+ .map((id) => document.getElementById(id)?.textContent?.trim() ?? "")
971
+ .filter(Boolean)
972
+ .join(" ");
973
+ if (text) return text;
974
+ }
975
+ if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
976
+ const label = element.labels?.[0]?.textContent?.trim();
977
+ if (label) return label;
978
+ return element.placeholder?.trim() ?? "";
979
+ }
980
+ if (element instanceof HTMLSelectElement) {
981
+ return element.labels?.[0]?.textContent?.trim() ?? "";
982
+ }
983
+ return element.textContent?.replace(/\s+/g, " ").trim().slice(0, 200) ?? "";
984
+ };
985
+ const describe = (element: Element): BrowserSessionControl => {
986
+ const tag = element.tagName.toLowerCase();
987
+ const role = element.getAttribute("role")?.trim();
988
+ let kind = role || tag;
989
+ if (element instanceof HTMLAnchorElement) kind = "link";
990
+ if (element instanceof HTMLButtonElement) kind = "button";
991
+ const attributes: Record<string, string | boolean> = {};
992
+ const type = element.getAttribute("type")?.trim();
993
+ if (type) attributes.type = type;
994
+ const name = element.getAttribute("name")?.trim();
995
+ if (name) attributes.name = name;
996
+ const autocomplete = element.getAttribute("autocomplete")?.trim();
997
+ if (autocomplete) attributes.autocomplete = autocomplete;
998
+ if (element instanceof HTMLAnchorElement && element.href) {
999
+ try {
1000
+ const href = new URL(element.href);
1001
+ href.username = "";
1002
+ href.password = "";
1003
+ href.search = "";
1004
+ href.hash = "";
1005
+ attributes.href = href.toString();
1006
+ } catch {
1007
+ // Ignore malformed link targets.
1008
+ }
1009
+ }
1010
+ if ("disabled" in element && (element as HTMLInputElement).disabled) {
1011
+ attributes.disabled = true;
1012
+ }
1013
+ if (
1014
+ (element instanceof HTMLInputElement && element.type !== "hidden") ||
1015
+ element instanceof HTMLTextAreaElement ||
1016
+ element instanceof HTMLSelectElement
1017
+ ) {
1018
+ return {
1019
+ kind,
1020
+ name: named(element),
1021
+ attributes,
1022
+ has_value: element.value.length > 0,
1023
+ };
1024
+ }
1025
+ return { kind, name: named(element), attributes };
1026
+ };
1027
+ const selector = [
1028
+ "a[href]",
1029
+ "button",
1030
+ "input:not([type=hidden])",
1031
+ "select",
1032
+ "textarea",
1033
+ '[role="button"]',
1034
+ '[role="link"]',
1035
+ '[role="checkbox"]',
1036
+ '[role="radio"]',
1037
+ '[role="textbox"]',
1038
+ '[role="combobox"]',
1039
+ ].join(",");
1040
+ const controls = Array.from(document.querySelectorAll(selector))
1041
+ .filter(visible)
1042
+ .slice(0, 500)
1043
+ .map(describe);
1044
+ const focus = document.activeElement;
1045
+ return {
1046
+ controls,
1047
+ focus: focus && focus !== document.body && visible(focus) ? describe(focus) : null,
1048
+ };
1049
+ });
1050
+ return {
1051
+ active_tab: tabIndex,
1052
+ url: page.url(),
1053
+ title: await page.title(),
1054
+ text: textResult.text,
1055
+ controls: observed.controls,
1056
+ focus: observed.focus,
1057
+ revision: this.sessionRevision,
1058
+ navigation: await this.describeNavigation(page),
1059
+ truncated: textResult.truncated,
1060
+ };
1061
+ }
1062
+
1063
+ async sessionScreenshot(outInput: string): Promise<BrowserSessionScreenshot> {
1064
+ const out = resolve(outInput);
1065
+ let parent: ReturnType<typeof statSync>;
1066
+ try {
1067
+ parent = statSync(dirname(out));
1068
+ } catch {
1069
+ throw new BrowserSessionActionError(
1070
+ "screenshot_parent_missing",
1071
+ "Screenshot parent directory must already exist.",
1072
+ );
1073
+ }
1074
+ if (!parent.isDirectory()) {
1075
+ throw new BrowserSessionActionError(
1076
+ "screenshot_parent_invalid",
1077
+ "Screenshot parent is not a directory.",
1078
+ );
1079
+ }
1080
+ const buffer = await this.currentPage.screenshot({ type: "png", fullPage: true });
1081
+ if (buffer.length < 24 || buffer.toString("ascii", 1, 4) !== "PNG") {
1082
+ throw new BrowserSessionActionError("screenshot_invalid", "Browser returned an invalid PNG.");
1083
+ }
1084
+ try {
1085
+ writeFileSync(out, buffer, { flag: "wx", mode: 0o600 });
1086
+ chmodSync(out, 0o600);
1087
+ } catch (error) {
1088
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") {
1089
+ throw new BrowserSessionActionError(
1090
+ "screenshot_exists",
1091
+ "Screenshot output already exists.",
1092
+ );
1093
+ }
1094
+ throw error;
1095
+ }
1096
+ this.sessionRevision++;
1097
+ return {
1098
+ path: out,
1099
+ width: buffer.readUInt32BE(16),
1100
+ height: buffer.readUInt32BE(20),
1101
+ revision: this.sessionRevision,
1102
+ };
1103
+ }
1104
+
1105
+ private sessionLocator(locator: BrowserSessionLocator): Locator {
1106
+ const page = this.currentPage;
1107
+ switch (locator.kind) {
1108
+ case "selector":
1109
+ return page.locator(locator.value);
1110
+ case "role":
1111
+ return page.getByRole(locator.value as Parameters<Page["getByRole"]>[0], {
1112
+ ...(locator.name === undefined ? {} : { name: locator.name, exact: !locator.partial }),
1113
+ });
1114
+ case "label":
1115
+ return page.getByLabel(locator.value, { exact: !locator.partial });
1116
+ case "text":
1117
+ return page.getByText(locator.value, { exact: !locator.partial });
1118
+ }
1119
+ }
1120
+
1121
+ private async strictSessionLocator(locator: BrowserSessionLocator): Promise<Locator> {
1122
+ return this.requireStrictLocator(this.sessionLocator(locator));
1123
+ }
1124
+
1125
+ private async requireStrictLocator(locator: Locator): Promise<Locator> {
1126
+ const count = await locator.count();
1127
+ if (count === 0) {
1128
+ throw new BrowserSessionActionError("locator_not_found", "Locator matched no elements.");
1129
+ }
1130
+ if (count !== 1) {
1131
+ throw new BrowserSessionActionError(
1132
+ "locator_ambiguous",
1133
+ `Locator matched ${count} elements; refine it before acting.`,
1134
+ );
1135
+ }
1136
+ return locator;
1137
+ }
1138
+
1139
+ private pageForIndex(index: number): Page {
1140
+ const entry = [...this.pageIndexes.entries()].find(
1141
+ ([page, pageIndex]) => pageIndex === index && !page.isClosed(),
1142
+ );
1143
+ if (!entry) throw new BrowserSessionActionError("tab_not_found", "Browser tab was not found.");
1144
+ return entry[0];
1145
+ }
1146
+
1147
+ private async describeTab(page: Page): Promise<BrowserSessionTab> {
1148
+ const index = this.pageIndexes.get(page);
1149
+ if (index === undefined || page.isClosed()) {
1150
+ throw new BrowserSessionActionError("tab_not_found", "Browser tab was not found.");
1151
+ }
1152
+ return {
1153
+ index,
1154
+ title: await page.title().catch(() => ""),
1155
+ url: page.url(),
1156
+ active: page === this.page,
1157
+ revision: this.sessionRevision,
1158
+ navigation: await this.describeNavigation(page),
1159
+ };
1160
+ }
1161
+
1162
+ private selectMostRecentPageIfNeeded(): void {
1163
+ if (!this.page || this.page.isClosed()) this.selectMostRecentPage();
1164
+ }
1165
+
664
1166
  /**
665
1167
  * Run occlusion checks on one or more selectors. For each, samples a grid
666
1168
  * of points inside the element's bounding rect and uses
@@ -826,6 +1328,12 @@ export class Browser {
826
1328
  await this.context.addInitScript(script);
827
1329
  }
828
1330
 
1331
+ /** Snapshot all cookies in the live persistent context. */
1332
+ async cookies(): Promise<PWCookie[]> {
1333
+ if (!this.context) throw new Error("Browser not opened. Call open() first.");
1334
+ return this.context.cookies();
1335
+ }
1336
+
829
1337
  /**
830
1338
  * Sync cookies from the live context back into the jar (if one was provided),
831
1339
  * then tear everything down. Safe to call multiple times.
@@ -845,6 +1353,12 @@ export class Browser {
845
1353
  await this.context.close().catch(() => {});
846
1354
  this.context = null;
847
1355
  this.page = null;
1356
+ this.pageIndexes.clear();
1357
+ this.pageRecency.clear();
1358
+ this.pageNavigations.clear();
1359
+ this.nextPageIndex = 0;
1360
+ this.recencyClock = 0;
1361
+ this.sessionRevision = 0;
848
1362
  }
849
1363
  }
850
1364
 
@@ -890,3 +1404,11 @@ function normalizeSameSite(s: string | undefined): "Strict" | "Lax" | "None" | u
890
1404
  if (lower === "none") return "None";
891
1405
  return undefined;
892
1406
  }
1407
+
1408
+ function truncateUtf8(value: string, maxBytes: number): { text: string; truncated: boolean } {
1409
+ const buffer = Buffer.from(value, "utf8");
1410
+ if (buffer.length <= maxBytes) return { text: value, truncated: false };
1411
+ let end = maxBytes;
1412
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
1413
+ return { text: buffer.subarray(0, end).toString("utf8"), truncated: true };
1414
+ }