@webhands/core 0.1.0 → 1.0.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 (49) hide show
  1. package/LICENSE +661 -0
  2. package/dist/cookies-export.d.ts +5 -5
  3. package/dist/cookies-export.d.ts.map +1 -1
  4. package/dist/cookies-export.js +4 -4
  5. package/dist/hand-host.d.ts +217 -0
  6. package/dist/hand-host.d.ts.map +1 -0
  7. package/dist/hand-host.js +351 -0
  8. package/dist/hand-host.js.map +1 -0
  9. package/dist/hand-loading.d.ts +128 -0
  10. package/dist/hand-loading.d.ts.map +1 -0
  11. package/dist/hand-loading.js +143 -0
  12. package/dist/hand-loading.js.map +1 -0
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/playwright-attach-transport.d.ts +9 -0
  18. package/dist/playwright-attach-transport.d.ts.map +1 -1
  19. package/dist/playwright-attach-transport.js +53 -91
  20. package/dist/playwright-attach-transport.js.map +1 -1
  21. package/dist/playwright-launch-transport.d.ts +7 -62
  22. package/dist/playwright-launch-transport.d.ts.map +1 -1
  23. package/dist/playwright-launch-transport.js +51 -204
  24. package/dist/playwright-launch-transport.js.map +1 -1
  25. package/dist/remote-session.d.ts +12 -2
  26. package/dist/remote-session.d.ts.map +1 -1
  27. package/dist/remote-session.js +37 -6
  28. package/dist/remote-session.js.map +1 -1
  29. package/dist/seam.d.ts +13 -5
  30. package/dist/seam.d.ts.map +1 -1
  31. package/dist/session-rpc.d.ts +76 -12
  32. package/dist/session-rpc.d.ts.map +1 -1
  33. package/dist/session-rpc.js +76 -8
  34. package/dist/session-rpc.js.map +1 -1
  35. package/dist/stub-transport.d.ts +2 -2
  36. package/dist/stub-transport.d.ts.map +1 -1
  37. package/dist/stub-transport.js +11 -0
  38. package/dist/stub-transport.js.map +1 -1
  39. package/package.json +18 -1
  40. package/src/cookies-export.ts +5 -5
  41. package/src/hand-host.ts +511 -0
  42. package/src/hand-loading.ts +254 -0
  43. package/src/index.ts +18 -1
  44. package/src/playwright-attach-transport.ts +65 -119
  45. package/src/playwright-launch-transport.ts +63 -244
  46. package/src/remote-session.ts +43 -5
  47. package/src/seam.ts +13 -5
  48. package/src/session-rpc.ts +121 -11
  49. package/src/stub-transport.ts +15 -3
@@ -1,25 +1,12 @@
1
1
  import {stat} from 'node:fs/promises';
2
- import {
3
- chromium,
4
- errors as pwErrors,
5
- type BrowserContext,
6
- type Page as PwPage,
7
- } from 'playwright';
2
+ import {chromium, type BrowserContext, type Page} from 'playwright';
8
3
  import {MissingBrowserBinaryError, MissingProfileError} from './errors.js';
4
+ import {composeWithHands, type Hand, type HandContext} from './hand-host.js';
9
5
  import {
10
6
  resolveProfileLocation,
11
7
  type ProfileLocationOptions,
12
8
  } from './profile-location.js';
13
- import type {
14
- Cookie,
15
- OpenTarget,
16
- Page,
17
- Session,
18
- Snapshot,
19
- SnapshotOptions,
20
- Transport,
21
- WaitCondition,
22
- } from './seam.js';
9
+ import type {OpenTarget, Session, Transport} from './seam.js';
23
10
 
24
11
  /**
25
12
  * The v1 concrete transport: a Playwright browser the controller LAUNCHES
@@ -40,14 +27,23 @@ import type {
40
27
  */
41
28
  export class PlaywrightLaunchTransport implements Transport {
42
29
  readonly #location: ProfileLocationOptions;
30
+ readonly #hands: readonly Hand[];
43
31
 
44
32
  /**
45
33
  * @param location overrides for where profiles live (a `root` dir and/or an
46
34
  * `env`). Omit in production to use `~/.webhands`; pass a temp
47
35
  * `root` in tests to isolate the shared profile location.
36
+ * @param hands explicitly-loaded third-party hands to compose alongside the
37
+ * built-ins (Phase 2, ADR-0007). These come from {@link loadHands} against
38
+ * the operator's explicit config; the transport does NOT discover them. Omit
39
+ * for the built-ins-only surface.
48
40
  */
49
- constructor(location: ProfileLocationOptions = {}) {
41
+ constructor(
42
+ location: ProfileLocationOptions = {},
43
+ hands: readonly Hand[] = [],
44
+ ) {
50
45
  this.#location = location;
46
+ this.#hands = hands;
51
47
  }
52
48
 
53
49
  async open(target: OpenTarget): Promise<Session> {
@@ -88,7 +84,7 @@ export class PlaywrightLaunchTransport implements Transport {
88
84
  // the single active page (PRD: single active session in v1). Create one if
89
85
  // the build ever changes that invariant.
90
86
  const pwPage = context.pages()[0] ?? (await context.newPage());
91
- return makeSession(context, pwPage);
87
+ return makeSession(context, pwPage, this.#hands);
92
88
  }
93
89
  }
94
90
 
@@ -119,8 +115,22 @@ function isMissingBrowserBinary(cause: unknown): boolean {
119
115
  );
120
116
  }
121
117
 
122
- /** Wrap a live Playwright persistent context into the seam's {@link Session}. */
123
- function makeSession(context: BrowserContext, pwPage: PwPage): Session {
118
+ /**
119
+ * Wrap a live Playwright persistent context into the seam's {@link Session}.
120
+ *
121
+ * The VERB surface comes from the shared hand-host ({@link composeBuiltInPage}),
122
+ * which is the single place the eight built-in verbs are composed (no duplicated
123
+ * page-object literal). Only the SESSION LIFECYCLE is per-transport here: the
124
+ * launch transport listens on the context's `'close'` event and its `close()`
125
+ * calls `context.close()`, which KILLS the browser this transport spawned
126
+ * (contrast the attach transport, which detaches without killing the user's
127
+ * browser, ADR-0002).
128
+ */
129
+ function makeSession(
130
+ context: BrowserContext,
131
+ pwPage: Page,
132
+ extraHands: readonly Hand[],
133
+ ): Session {
124
134
  let closed = false;
125
135
  const ensureOpen = () => {
126
136
  if (closed) {
@@ -128,236 +138,45 @@ function makeSession(context: BrowserContext, pwPage: PwPage): Session {
128
138
  }
129
139
  };
130
140
 
131
- const page: Page = {
132
- async navigate(url: string): Promise<void> {
133
- ensureOpen();
134
- // "Settled" for `goto` = the `load` event: the document and its
135
- // subresources have loaded (PRD story 6, "navigate ... and wait for it
136
- // to settle"). We deliberately do NOT wait for `networkidle`:
137
- // Playwright discourages it, and it hangs forever on pages with
138
- // long-poll / streaming / analytics beacons (exactly the logged-in apps
139
- // this tool targets). Content rendered AFTER load (XHR-injected prices,
140
- // hydrated lists) is the job of the explicit `wait` verb (story 10), not
141
- // of `goto`.
142
- await pwPage.goto(url, {waitUntil: 'load'});
143
- },
144
- async snapshot(options?: SnapshotOptions): Promise<Snapshot> {
145
- ensureOpen();
146
- const url = pwPage.url();
147
- if (options?.full === true) {
148
- // `--full`: the raw DOM. `documentElement.outerHTML` is the serialized
149
- // live DOM (post-script render), which is what an agent that wants the
150
- // real HTML expects — not the original network response.
151
- const content = await pwPage.evaluate(
152
- () => document.documentElement.outerHTML,
153
- );
154
- return {url, view: 'full', content};
155
- }
156
- // Default: the token-cheap accessibility tree + visible text with stable
157
- // `[ref=...]` element refs. Playwright's `ariaSnapshot({mode: 'ai'})`
158
- // emits exactly that — a YAML aria tree (roles + accessible names +
159
- // text) where each node carries a stable `[ref=eN]` reference, assigned
160
- // deterministically by traversal order so re-snapshotting an unchanged
161
- // page yields the same refs. The string crosses the seam as opaque,
162
- // transport-neutral text (no Playwright type leaks, ADR-0003).
163
- const content = await pwPage.ariaSnapshot({mode: 'ai'});
164
- return {url, view: 'accessibility', content};
165
- },
166
- async click(t): Promise<void> {
167
- ensureOpen();
168
- await clickLocator(pwPage, t);
169
- },
170
- async type(t, text): Promise<void> {
171
- ensureOpen();
172
- await resolveLocator(pwPage, t).fill(text);
173
- },
174
- async eval(expression: string): Promise<unknown> {
175
- ensureOpen();
176
- // The `eval` escape hatch (PRD story 9): run the raw JS EXPRESSION in the
177
- // page and return its serializable result. Playwright's `evaluate`
178
- // already IS the seam's serialization contract (see {@link Page.eval}):
179
- // it passes a string as an expression, awaits a returned Promise, and
180
- // structurally clones the result out of the page by VALUE. That clone is
181
- // richer than JSON: it preserves NaN/Infinity/BigInt and circular
182
- // structures (back-refs become a `[Circular]` marker), yields `undefined`
183
- // for functions/symbols, and returns an opaque preview string for a live
184
- // host object (a DOM node never crosses the process boundary). A page-side
185
- // throw rejects. We pass it straight through rather than re-encode it:
186
- // wrapping the value in a transport-specific envelope would invent a
187
- // dialect the seam deliberately avoids. The thrown error is a plain
188
- // `Error`, so no Playwright/CDP type leaks across the seam (ADR-0003).
189
- return pwPage.evaluate(expression);
190
- },
191
- async wait(condition: WaitCondition): Promise<void> {
192
- ensureOpen();
193
- await waitFor(pwPage, condition);
194
- },
195
- async cookies(): Promise<readonly Cookie[]> {
196
- ensureOpen();
197
- const raw = await context.cookies();
198
- return raw.map(toSeamCookie);
199
- },
200
- async setCookies(cookies): Promise<void> {
201
- ensureOpen();
202
- await context.addCookies(cookies.map(fromSeamCookie));
203
- },
141
+ // Resolves the first time the context is gone — whether the USER closed the
142
+ // window (Playwright fires the context 'close' event) or our own close()
143
+ // ran. This is what lets `setup-profile` hold the headed window open and
144
+ // block on waitForClose() until the human is done.
145
+ let resolveClosed!: () => void;
146
+ const closedSignal = new Promise<void>((resolve) => {
147
+ resolveClosed = resolve;
148
+ });
149
+ const markClosed = () => {
150
+ if (closed) return;
151
+ closed = true;
152
+ resolveClosed();
204
153
  };
154
+ context.on('close', markClosed);
155
+
156
+ // Build the verb surface from the built-in hands over a live hand-context.
157
+ // The host keeps the live `pwPage`/`context` in-process (they never cross the
158
+ // seam, ADR-0003); the hand-context carries live page access only.
159
+ const handContext: HandContext = {pwPage, context, ensureOpen};
160
+ const {page, dispose: disposeHands} = composeWithHands(
161
+ handContext,
162
+ extraHands,
163
+ );
205
164
 
206
165
  return {
207
166
  page,
208
167
  async close(): Promise<void> {
209
- if (closed) return;
210
- closed = true;
168
+ if (closed) {
169
+ return;
170
+ }
171
+ // Dispose the hands first (their in-process resources), THEN tear down
172
+ // the browser: context.close() fires the 'close' event, which runs
173
+ // markClosed and KILLS the browser this transport spawned.
174
+ await disposeHands();
211
175
  await context.close();
176
+ markClosed();
177
+ },
178
+ waitForClose(): Promise<void> {
179
+ return closedSignal;
212
180
  },
213
- };
214
- }
215
-
216
- /**
217
- * Run the `wait` verb's three forms (PRD story 10) against a Playwright page.
218
- *
219
- * - `timeout` — pace by a fixed delay (`waitForTimeout`), so an agent can act
220
- * like a human and let XHR-rendered content land.
221
- * - `locator` — block until the addressed element appears (`Locator.waitFor()`),
222
- * the form for content rendered AFTER `goto` settled on `load`.
223
- * - `navigation` — block until the NEXT navigation settles to `load`. We use
224
- * `waitForNavigation()` even though Playwright marks it `@deprecated` ("racy,
225
- * use waitForURL"): that deprecation targets in-process TEST code that can arm
226
- * the wait BEFORE the action and pass a target URL. Neither holds here. Across
227
- * this seam verbs are DISCRETE sequential calls (`click` then `wait`), so we
228
- * CANNOT arm before the trigger; and the realistic trigger is an async,
229
- * JS-driven transition (a redirect / SPA route change that fires AFTER the
230
- * agent's action, the "let XHR-rendered content load" case of story 10), so
231
- * "wait for the NEXT navigation" is exactly right — whereas `waitForLoadState`
232
- * would see the already-loaded current page and return before the pending
233
- * transition. `waitForURL` is unusable because the verb has no target URL by
234
- * design (the agent waits for "a navigation", not a known address). (See the
235
- * task's ## Decisions note.)
236
- *
237
- * Shared by both Playwright transports so the verb behaviour stays identical
238
- * (the forward-note's "do NOT write a parallel second implementation").
239
- */
240
- export async function waitFor(
241
- page: PwPage,
242
- condition: WaitCondition,
243
- ): Promise<void> {
244
- switch (condition.kind) {
245
- case 'timeout':
246
- await page.waitForTimeout(condition.ms);
247
- return;
248
- case 'locator':
249
- await resolveLocator(page, condition.target).waitFor();
250
- return;
251
- case 'navigation':
252
- // eslint-disable-next-line @typescript-eslint/no-deprecated
253
- await page.waitForNavigation();
254
- return;
255
- }
256
- }
257
-
258
- /**
259
- * Resolve a raw Playwright locator EXPRESSION (ADR-0004) against the page. The
260
- * verb surface passes locator expressions like `getByRole('button', …)`; we
261
- * evaluate them in a small sandbox where `page`/`p` is the page, so the full
262
- * Playwright locator grammar is available without leaking the type across the
263
- * seam.
264
- *
265
- * Exported (with {@link clickLocator}/{@link waitFor}) so the attach transport
266
- * resolves locators IDENTICALLY — one resolution path, no parallel addressing
267
- * scheme (the forward-note's "do NOT write a parallel second implementation").
268
- */
269
- export function resolveLocator(page: PwPage, expression: string) {
270
- // eslint-disable-next-line no-new-func
271
- const factory = new Function('page', 'p', `return (${expression});`) as (
272
- page: PwPage,
273
- p: PwPage,
274
- ) => ReturnType<PwPage['locator']>;
275
- return factory(page, page);
276
- }
277
-
278
- /**
279
- * How long a normal, actionability-checked `click` may wait before we treat the
280
- * element as un-clickable and fall back to a dispatched click. Short on purpose:
281
- * a hidden custom input never becomes actionable, so the regular click would
282
- * otherwise burn Playwright's full default timeout (30s) before the escape path
283
- * runs. The visible-element happy path clicks immediately and never hits this;
284
- * this bound is the latency cost paid ONLY on the hidden/non-actionable path,
285
- * and is long enough to tolerate a slow-but-eventually-actionable element
286
- * (animations, late layout) before deciding to dispatch.
287
- */
288
- const NORMAL_CLICK_TIMEOUT_MS = 1_000;
289
-
290
- /**
291
- * Run the `click` verb against a Playwright page (PRD story 8), shared by both
292
- * Playwright transports so the verb behaves identically (mirrors {@link waitFor};
293
- * the forward-note's "do NOT write a parallel second implementation").
294
- *
295
- * First try a normal `Locator.click()`, which AUTO-WAITS for the element to be
296
- * visible and actionable — the right behaviour for a real button. A hidden
297
- * custom input (the case the prd calls out) NEVER becomes actionable, so that
298
- * click times out; on a Playwright `TimeoutError` we fall back to
299
- * `dispatchEvent('click')`, which fires a click WITHOUT the actionability
300
- * checks. The fallback is deliberately the documented Playwright escape (a
301
- * sibling to the `eval` hatch, ADR-0004), not a reimplemented click: we keep
302
- * the locator a raw resolved expression and only change HOW the resolved
303
- * locator is clicked.
304
- *
305
- * Only a timeout triggers the fallback. The fallback `dispatchEvent` is itself
306
- * bounded by the same short timeout, so a locator that resolves NO element (a
307
- * bad locator) surfaces its timeout quickly instead of hanging the dispatch on
308
- * Playwright's 30s default — the dispatch escape is for elements that EXIST but
309
- * are not actionable (hidden custom inputs), not for absent ones.
310
- */
311
- export async function clickLocator(
312
- page: PwPage,
313
- expression: string,
314
- ): Promise<void> {
315
- const target = resolveLocator(page, expression);
316
- try {
317
- await target.click({timeout: NORMAL_CLICK_TIMEOUT_MS});
318
- } catch (cause) {
319
- if (!(cause instanceof pwErrors.TimeoutError)) {
320
- throw cause;
321
- }
322
- // The element never became actionable (e.g. a hidden custom input). Fire
323
- // the click without actionability checks, the prd's explicit escape path.
324
- await target.dispatchEvent('click', {timeout: NORMAL_CLICK_TIMEOUT_MS});
325
- }
326
- }
327
-
328
- /** Map a Playwright cookie to the transport-neutral seam {@link Cookie}. */
329
- function toSeamCookie(c: {
330
- name: string;
331
- value: string;
332
- domain?: string;
333
- path?: string;
334
- expires?: number;
335
- httpOnly?: boolean;
336
- secure?: boolean;
337
- sameSite?: 'Strict' | 'Lax' | 'None';
338
- }): Cookie {
339
- return {
340
- name: c.name,
341
- value: c.value,
342
- domain: c.domain,
343
- path: c.path,
344
- expires: c.expires,
345
- httpOnly: c.httpOnly,
346
- secure: c.secure,
347
- sameSite: c.sameSite,
348
- };
349
- }
350
-
351
- /** Map a seam {@link Cookie} to a Playwright cookie shape. */
352
- function fromSeamCookie(c: Cookie) {
353
- return {
354
- name: c.name,
355
- value: c.value,
356
- domain: c.domain,
357
- path: c.path,
358
- expires: c.expires,
359
- httpOnly: c.httpOnly,
360
- secure: c.secure,
361
- sameSite: c.sameSite,
362
181
  };
363
182
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ callHandVerb,
2
3
  makeRpcPage,
3
4
  SESSION_RPC_PATH,
4
5
  type SessionRpcRequest,
@@ -11,7 +12,7 @@ import type {Session} from './seam.js';
11
12
  * long-lived `serve` process over HTTP (ADR-0005).
12
13
  *
13
14
  * Each `webhands <verb>` is a thin client: it cannot hold a JS
14
- * reference to the server's live page, so this proxy turns every {@link Page}
15
+ * reference to the server's live page, so this proxy turns every {@link WebHandsPage}
15
16
  * verb into a session-RPC call to the running server (see `session-rpc.ts`) and
16
17
  * returns the result. The verb command code is UNCHANGED — it still calls
17
18
  * `provider(target)` then runs verbs against the returned `Session.page` then
@@ -24,8 +25,21 @@ import type {Session} from './seam.js';
24
25
  * (`stop`), exactly as ADR-0005 requires. This is the whole reason cross-
25
26
  * invocation persistence works: the page state survives because the client's
26
27
  * `close()` does not reach across to the server's session.
28
+ *
29
+ * THIRD-PARTY HAND VERBS (Phase 2, Model B; ADR-0007). Pass the NAMES of the
30
+ * hand verbs the served process loaded as `handVerbs`; each is attached to the
31
+ * returned `page` as a dynamic method forwarding over the RPC via
32
+ * {@link callHandVerb}, so the agent gains those tools WITHOUT ever holding a
33
+ * live page handle. They are NOT on the seam `WebHandsPage` type (the seam knows only
34
+ * the eight built-ins), so a caller reaches them through a cast, exactly as a
35
+ * third-party hand verb is reached on the in-process composed page. The result
36
+ * crosses the wire as a serializable value and a page/in-hand throw rejects
37
+ * faithfully, the same contract as the built-in verbs.
27
38
  */
28
- export function connectRemoteSession(baseUrl: string): Session {
39
+ export function connectRemoteSession(
40
+ baseUrl: string,
41
+ handVerbs: readonly string[] = [],
42
+ ): Session {
29
43
  const endpoint = new URL(SESSION_RPC_PATH, baseUrl).toString();
30
44
 
31
45
  const send = async (request: SessionRpcRequest): Promise<unknown> => {
@@ -56,11 +70,35 @@ export function connectRemoteSession(baseUrl: string): Session {
56
70
  throw new Error(reply.error);
57
71
  };
58
72
 
73
+ let resolveClosed!: () => void;
74
+ const closedSignal = new Promise<void>((resolve) => {
75
+ resolveClosed = resolve;
76
+ });
77
+
78
+ const page = makeRpcPage(send);
79
+ // Attach each loaded hand verb as a dynamic method that forwards over the same
80
+ // RPC `send`. The seam `WebHandsPage` type names only the built-ins, so these live on
81
+ // the runtime object alongside them (mirroring how a hand verb composes into
82
+ // the in-process page object); callers reach them through a cast.
83
+ const pageWithHands = page as unknown as Record<string, unknown>;
84
+ for (const name of handVerbs) {
85
+ pageWithHands[name] = (...args: readonly unknown[]): Promise<unknown> =>
86
+ callHandVerb(send, name, ...args);
87
+ }
88
+
59
89
  return {
60
- page: makeRpcPage(send),
90
+ page,
61
91
  async close() {
62
- // Intentionally a no-op: the served process owns the session's lifetime
63
- // (see this module's overview). Teardown is the explicit `stop` verb.
92
+ // Intentionally a no-op against the SERVER: the served process owns the
93
+ // session's lifetime (see this module's overview). Teardown is the
94
+ // explicit `stop` verb. We still resolve the local close signal so a
95
+ // caller awaiting waitForClose() on this client handle unblocks.
96
+ resolveClosed();
97
+ },
98
+ waitForClose(): Promise<void> {
99
+ // A client never waits on the user closing the window — that is the
100
+ // server's concern; this resolves on a local close() call.
101
+ return closedSignal;
64
102
  },
65
103
  };
66
104
  }
package/src/seam.ts CHANGED
@@ -74,7 +74,7 @@ export interface Cookie {
74
74
  readonly sameSite?: 'Strict' | 'Lax' | 'None';
75
75
  }
76
76
 
77
- /** What to wait for in the {@link Page.wait} verb. */
77
+ /** What to wait for in the {@link WebHandsPage.wait} verb. */
78
78
  export type WaitCondition =
79
79
  | {readonly kind: 'timeout'; readonly ms: number}
80
80
  | {readonly kind: 'locator'; readonly target: LocatorString}
@@ -93,7 +93,7 @@ export type WaitCondition =
93
93
  */
94
94
  export type SnapshotView = 'accessibility' | 'full';
95
95
 
96
- /** Options for the {@link Page.snapshot} verb. */
96
+ /** Options for the {@link WebHandsPage.snapshot} verb. */
97
97
  export interface SnapshotOptions {
98
98
  /**
99
99
  * When `true`, return the raw DOM (`view: 'full'`) instead of the default
@@ -132,7 +132,7 @@ export interface Snapshot {
132
132
  * The page-level verb surface. One method per verb in the domain glossary.
133
133
  * All element addressing flows through {@link LocatorString}.
134
134
  */
135
- export interface Page {
135
+ export interface WebHandsPage {
136
136
  /** Navigate the active page to a URL and let it settle. */
137
137
  navigate(url: string): Promise<void>;
138
138
  /**
@@ -196,16 +196,24 @@ export interface Page {
196
196
  }
197
197
 
198
198
  /**
199
- * A live browser session owning one active {@link Page}. The session lifetime
199
+ * A live browser session owning one active {@link WebHandsPage}. The session lifetime
200
200
  * spans from {@link Transport.open} to {@link Session.close}; it is the unit a
201
201
  * long-lived controller process keeps between CLI invocations (PRD
202
202
  * "session/daemon question").
203
203
  */
204
204
  export interface Session {
205
205
  /** The active page the verbs act on. */
206
- readonly page: Page;
206
+ readonly page: WebHandsPage;
207
207
  /** Tear down the session and release the underlying browser resources. */
208
208
  close(): Promise<void>;
209
+ /**
210
+ * Resolve when the session is closed — either by the USER closing the browser
211
+ * window/context, or by a {@link Session.close} call. This is what lets a
212
+ * headed flow (notably `setup-profile`) HOLD the window open and block until
213
+ * the human is done, instead of tearing it down immediately. Resolves once
214
+ * (idempotent); resolves immediately if the session is already closed.
215
+ */
216
+ waitForClose(): Promise<void>;
209
217
  }
210
218
 
211
219
  /**