@mind-harness/mh-browser 0.2.4 → 0.2.6

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.
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
1
+ import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, win32 } from "node:path";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
4
4
  import { isDeepStrictEqual } from "node:util";
@@ -6,11 +6,13 @@ import { HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
6
6
  import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
8
  import { parse } from "yaml";
9
- import { constants, readFileSync } from "node:fs";
9
+ import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs";
10
10
  import { lstat, mkdir, open, readFile, readlink, realpath, unlink, writeFile } from "node:fs/promises";
11
11
  import { z as z$1 } from "zod";
12
12
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
13
13
  import { chromium } from "playwright";
14
+ import { execFile } from "node:child_process";
15
+ import { homedir } from "node:os";
14
16
  import { fileURLToPath } from "node:url";
15
17
  import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
16
18
  //#region lib/types/config.js
@@ -20,7 +22,8 @@ const browserConfig = z.object({
20
22
  "chrome",
21
23
  "msedge",
22
24
  "chromium"
23
- ]).default("chrome").description("Which installed browser to drive. chromium is the one Playwright downloads."),
25
+ ]).default("chrome").volatile().description("Which browser to drive. chromium is the one Playwright downloads. A new value restarts the browser once it is idle."),
26
+ executablePath: z.string().volatile().description("Absolute path of the browser executable. `~` is expanded. Leave empty to locate one. A new value restarts the browser once it is idle."),
24
27
  profileDir: z.string().description("Browser profile directory, kept between runs. Defaults to mh/profiles/<dsh profile>/browser under the dsh home."),
25
28
  headless: z.boolean().default(false).description("Run the browser without a window.")
26
29
  });
@@ -33,6 +36,146 @@ function defaultProfileDir(profile, home = resolveDshHome()) {
33
36
  return profile === void 0 ? join(home, "mh", "browser") : join(home, "mh", "profiles", profile.name, "browser");
34
37
  }
35
38
  //#endregion
39
+ //#region lib/types/types.js
40
+ var MhBrowserError = class extends Error {
41
+ code;
42
+ page;
43
+ constructor(code, message, page) {
44
+ super(message);
45
+ this.name = "MhBrowserError";
46
+ this.code = code;
47
+ if (page !== void 0) this.page = page;
48
+ }
49
+ };
50
+ /** Owner of the person's own window. It belongs to no Session. */
51
+ const PERSON_OWNER = "person";
52
+ const UNKNOWN_TAB = "UNKNOWN_TAB";
53
+ const UNKNOWN_TAB_REASON = "no such tab in this session";
54
+ /**
55
+ * Internal: a wait ended because its signal aborted. A tool never reports it:
56
+ * a stopped call ends as the tool runtime's abort, a takeover as `TAKEN_OVER`.
57
+ */
58
+ const INTERRUPTED = "INTERRUPTED";
59
+ /** The browser did not start or could not be restarted. */
60
+ const BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
61
+ function unknownTab() {
62
+ return {
63
+ url: "",
64
+ reason: UNKNOWN_TAB_REASON
65
+ };
66
+ }
67
+ //#endregion
68
+ //#region lib/types/service/abortable.js
69
+ const WAITING_STOPPED$1 = "stopped while waiting";
70
+ /** A wait ended because its signal aborted, including a deadline synthesized here. */
71
+ function stopped$1() {
72
+ return new MhBrowserError(INTERRUPTED, WAITING_STOPPED$1);
73
+ }
74
+ function isInterrupted(error) {
75
+ return error instanceof MhBrowserError && error.code === "INTERRUPTED";
76
+ }
77
+ /**
78
+ * `promise`, or a rejection when `signal` aborts. The original promise is
79
+ * always observed, so abandoning it leaves no unhandled rejection.
80
+ */
81
+ function untilAborted(promise, signal) {
82
+ const settled = promise.then((value) => ({
83
+ ok: true,
84
+ value
85
+ }), (error) => ({
86
+ ok: false,
87
+ error
88
+ }));
89
+ if (signal === void 0) return unwrap(settled);
90
+ return new Promise((resolve, reject) => {
91
+ let done = false;
92
+ const finish = (run) => {
93
+ if (done) return;
94
+ done = true;
95
+ signal.removeEventListener("abort", onAbort);
96
+ run();
97
+ };
98
+ const onAbort = () => {
99
+ finish(() => reject(stopped$1()));
100
+ };
101
+ if (signal.aborted) {
102
+ finish(() => reject(stopped$1()));
103
+ return;
104
+ }
105
+ signal.addEventListener("abort", onAbort, { once: true });
106
+ settled.then((result) => {
107
+ finish(() => {
108
+ if (result.ok) resolve(result.value);
109
+ else reject(result.error);
110
+ });
111
+ });
112
+ });
113
+ }
114
+ /** `signal`, a deadline of `ms`, or whichever of the two aborts first. */
115
+ function capped(signal, ms) {
116
+ const limit = AbortSignal.timeout(ms);
117
+ if (signal === void 0) return limit;
118
+ return AbortSignal.any([signal, limit]);
119
+ }
120
+ /**
121
+ * Run `op` once `previous` settles. Aborting `signal` while waiting rejects
122
+ * `run` and does not call `op`. `tail` still follows `previous`, and `op`
123
+ * when `op` has started, so a later caller cannot overlap the work.
124
+ */
125
+ function enqueue(previous, op, signal) {
126
+ let started = Promise.resolve();
127
+ let began = false;
128
+ return {
129
+ run: new Promise((resolve, reject) => {
130
+ let settled = false;
131
+ const fail = () => {
132
+ if (settled) return;
133
+ settled = true;
134
+ reject(stopped$1());
135
+ };
136
+ const onAbort = () => {
137
+ if (began) return;
138
+ fail();
139
+ };
140
+ const start = () => {
141
+ signal?.removeEventListener("abort", onAbort);
142
+ if (settled || signal?.aborted === true) {
143
+ fail();
144
+ return;
145
+ }
146
+ let work;
147
+ try {
148
+ began = true;
149
+ work = op();
150
+ } catch (error) {
151
+ started = Promise.reject(error);
152
+ started.catch(() => void 0);
153
+ if (!settled) {
154
+ settled = true;
155
+ reject(error);
156
+ }
157
+ return;
158
+ }
159
+ started = work;
160
+ resolve(work);
161
+ };
162
+ if (signal?.aborted === true) {
163
+ fail();
164
+ return;
165
+ }
166
+ if (signal !== void 0) signal.addEventListener("abort", onAbort, { once: true });
167
+ previous.then(start, start);
168
+ }),
169
+ tail: previous.then(() => started, () => started).then(() => void 0, () => void 0)
170
+ };
171
+ }
172
+ function unwrap(settled) {
173
+ return settled.then((result) => {
174
+ if (result.ok) return result.value;
175
+ throw result.error;
176
+ });
177
+ }
178
+ //#endregion
36
179
  //#region lib/types/representation/agent-view.js
37
180
  const AGENT_VIEW_VERSION = "0.1";
38
181
  const AGENT_VIEW_ACCEPT = "text/markdown; variant=agent-view";
@@ -446,35 +589,6 @@ function formatSeconds(seconds) {
446
589
  return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
447
590
  }
448
591
  //#endregion
449
- //#region lib/types/types.js
450
- var MhBrowserError = class extends Error {
451
- code;
452
- page;
453
- constructor(code, message, page) {
454
- super(message);
455
- this.name = "MhBrowserError";
456
- this.code = code;
457
- if (page !== void 0) this.page = page;
458
- }
459
- };
460
- /** Owner of the person's own window. It belongs to no Session. */
461
- const PERSON_OWNER = "person";
462
- const UNKNOWN_TAB = "UNKNOWN_TAB";
463
- const UNKNOWN_TAB_REASON = "no such tab in this session";
464
- /**
465
- * Internal: a wait ended because its signal aborted. A tool never reports it:
466
- * a stopped call ends as the tool runtime's abort, a takeover as `TAKEN_OVER`.
467
- */
468
- const INTERRUPTED = "INTERRUPTED";
469
- /** The browser did not start or could not be restarted. */
470
- const BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
471
- function unknownTab() {
472
- return {
473
- url: "",
474
- reason: UNKNOWN_TAB_REASON
475
- };
476
- }
477
- //#endregion
478
592
  //#region lib/types/tools/session.js
479
593
  /** dsh shows the model only the message, so the message starts with the code. */
480
594
  var BrowserToolError = class extends HarnessError {
@@ -694,6 +808,18 @@ function visit(value, depth, found) {
694
808
  for (const item of Object.values(row)) visit(item, depth + 1, found);
695
809
  }
696
810
  //#endregion
811
+ //#region lib/types/tools/given.js
812
+ /** A string the caller gave. Blank is the same as omitting it; the text itself is kept. */
813
+ function givenString(value) {
814
+ if (typeof value !== "string" || value.trim() === "") return void 0;
815
+ return value;
816
+ }
817
+ /** Seconds to wait. Zero is the same as omitting it; any other number is kept. */
818
+ function givenTime(value) {
819
+ if (typeof value !== "number" || value === 0) return void 0;
820
+ return value;
821
+ }
822
+ //#endregion
697
823
  //#region lib/types/tools/delivery.js
698
824
  /** Every mh-browser tool result's `meta.by`; a result without it is not a delivery. */
699
825
  const META_BY = "mh-browser";
@@ -1370,9 +1496,9 @@ const OPS = [
1370
1496
  },
1371
1497
  build: async (args) => ({
1372
1498
  kind: "wait_for",
1373
- ...typeof args.text === "string" ? { text: args.text } : {},
1374
- ...typeof args.textGone === "string" ? { textGone: args.textGone } : {},
1375
- ...typeof args.time === "number" ? { time: args.time } : {}
1499
+ ...stringField("text", args.text),
1500
+ ...stringField("textGone", args.textGone),
1501
+ ...timeField(args.time)
1376
1502
  })
1377
1503
  },
1378
1504
  {
@@ -1399,7 +1525,7 @@ const OPS = [
1399
1525
  filename: true,
1400
1526
  build: async (args) => ({
1401
1527
  kind: "snapshot",
1402
- ...typeof args.target === "string" ? { target: args.target } : {},
1528
+ ...stringField("target", args.target),
1403
1529
  ...typeof args.depth === "number" ? { depth: args.depth } : {},
1404
1530
  ...args.boxes === true ? { boxes: true } : {}
1405
1531
  })
@@ -1428,8 +1554,8 @@ const OPS = [
1428
1554
  filename: true,
1429
1555
  build: async (args) => ({
1430
1556
  kind: "find",
1431
- ...typeof args.text === "string" ? { text: args.text } : {},
1432
- ...typeof args.regex === "string" ? { regex: args.regex } : {},
1557
+ ...stringField("text", args.text),
1558
+ ...stringField("regex", args.regex),
1433
1559
  ...typeof args.maxResults === "number" ? { maxResults: args.maxResults } : {}
1434
1560
  })
1435
1561
  },
@@ -1473,7 +1599,7 @@ const OPS = [
1473
1599
  build: async (args) => ({
1474
1600
  kind: "take_screenshot",
1475
1601
  ...typeof args.element === "string" ? { element: args.element } : {},
1476
- ...typeof args.target === "string" ? { target: args.target } : {},
1602
+ ...stringField("target", args.target),
1477
1603
  ...args.type === "png" || args.type === "jpeg" || args.type === "webp" ? { type: args.type } : {},
1478
1604
  ...args.fullPage === true ? { fullPage: true } : {},
1479
1605
  ...args.scale === "css" || args.scale === "device" ? { scale: args.scale } : {}
@@ -1507,7 +1633,7 @@ const OPS = [
1507
1633
  function: string(args.function),
1508
1634
  callId: callIdOf(exec),
1509
1635
  ...typeof args.element === "string" ? { element: args.element } : {},
1510
- ...typeof args.target === "string" ? { target: args.target } : {}
1636
+ ...stringField("target", args.target)
1511
1637
  })
1512
1638
  }
1513
1639
  ];
@@ -1542,7 +1668,7 @@ function defineOperation(ctx, reader, spec) {
1542
1668
  const target = writeTarget(ctx, session);
1543
1669
  ctx.mhBrowser.noteWorkspace(session.id, target);
1544
1670
  const input = args;
1545
- const filename = spec.filename === true && typeof input.filename === "string" ? input.filename : void 0;
1671
+ const filename = spec.filename === true ? givenString(input.filename) : void 0;
1546
1672
  if (filename !== void 0) await assertInside(target, filename);
1547
1673
  try {
1548
1674
  const tab = tabOf(reader, session, args.tab);
@@ -1600,6 +1726,18 @@ function string(value) {
1600
1726
  function stringList$1(value) {
1601
1727
  return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1602
1728
  }
1729
+ function stringField(key, value) {
1730
+ const given = givenString(value);
1731
+ if (given === void 0) return {};
1732
+ if (key === "target") return { target: given };
1733
+ if (key === "text") return { text: given };
1734
+ if (key === "textGone") return { textGone: given };
1735
+ return { regex: given };
1736
+ }
1737
+ function timeField(value) {
1738
+ const given = givenTime(value);
1739
+ return given === void 0 ? {} : { time: given };
1740
+ }
1603
1741
  function optionalString(args, key) {
1604
1742
  return typeof args[key] === "string" ? { element: args[key] } : {};
1605
1743
  }
@@ -1911,6 +2049,8 @@ function takeoverText(taken) {
1911
2049
  }
1912
2050
  //#endregion
1913
2051
  //#region lib/types/presence/register.js
2052
+ /** A presence read that stops answering. One stuck page must not hold the next step. */
2053
+ const CHECK_MS = 6e4;
1914
2054
  const BROWSER_TOOLS = /* @__PURE__ */ new Set([
1915
2055
  "browser_open",
1916
2056
  "browser_invoke",
@@ -1931,14 +2071,17 @@ function registerPresence(ctx, reader) {
1931
2071
  ctx.effect(() => () => {
1932
2072
  lifetime.abort();
1933
2073
  });
1934
- const waitForChecks = async (agent) => {
2074
+ const waitForChecks = async (agent, signal) => {
1935
2075
  let pending;
1936
- while ((pending = tails.get(agent)) !== void 0) await pending;
2076
+ while ((pending = tails.get(agent)) !== void 0) {
2077
+ await untilAborted(pending, signal).catch(() => void 0);
2078
+ if (signal.aborted) return;
2079
+ }
1937
2080
  };
1938
2081
  const queue = (agent, urls) => {
1939
2082
  const current = (tails.get(agent) ?? Promise.resolve()).then(async () => {
1940
2083
  if (lifetime.signal.aborted) return;
1941
- const reminder = await ctx.mhBrowser.presenceCheck(agent.session.id, urls, { signal: lifetime.signal });
2084
+ const reminder = await ctx.mhBrowser.presenceCheck(agent.session.id, urls, { signal: AbortSignal.any([lifetime.signal, AbortSignal.timeout(CHECK_MS)]) });
1942
2085
  if (reminder === void 0 || lifetime.signal.aborted) return;
1943
2086
  syncInbox(agent, reader.presence(agent.session), [], reminderMessage(reminder));
1944
2087
  }).catch((error) => {
@@ -2015,7 +2158,7 @@ function registerPresence(ctx, reader) {
2015
2158
  agents.set(agent.session.id, agent);
2016
2159
  const handed = messages.some((message) => message.source.kind === "user") ? ctx.mhBrowser.takePersonHandback(agent.session.id) : void 0;
2017
2160
  if (handed !== void 0 && handed.urls.length > 0) queue(agent, handed.urls);
2018
- await waitForChecks(agent);
2161
+ await waitForChecks(agent, signal);
2019
2162
  signal.throwIfAborted();
2020
2163
  const desired = await compose(ctx.mhBrowser, agent, reader.presence(agent.session), messages, signal);
2021
2164
  signal.throwIfAborted();
@@ -2159,11 +2302,10 @@ function record(value) {
2159
2302
  return {};
2160
2303
  }
2161
2304
  function checkOptions(signal, extra) {
2162
- const options = {};
2305
+ const options = { signal: capped(signal, CHECK_MS) };
2163
2306
  if (extra?.baseline === true) options.baseline = true;
2164
2307
  if (extra?.replaces === true) options.replaces = true;
2165
2308
  if (extra?.force === true) options.force = true;
2166
- if (signal !== void 0) options.signal = signal;
2167
2309
  return options;
2168
2310
  }
2169
2311
  function canonical$1(url) {
@@ -2930,17 +3072,268 @@ function sameOrigin$1(left, right) {
2930
3072
  }
2931
3073
  }
2932
3074
  //#endregion
2933
- //#region lib/types/service/browser.js
2934
- const MISSING = {
2935
- chrome: "Google Chrome not found; install it, or change `browser` in the configuration",
2936
- msedge: "Microsoft Edge not found; install it, or change `browser` in the configuration",
2937
- chromium: "Playwright's Chromium not found; install it, or change `browser` in the configuration"
3075
+ //#region lib/types/service/locate.js
3076
+ const MAC_APP = {
3077
+ chrome: [
3078
+ "Google Chrome.app",
3079
+ "Contents",
3080
+ "MacOS",
3081
+ "Google Chrome"
3082
+ ],
3083
+ msedge: [
3084
+ "Microsoft Edge.app",
3085
+ "Contents",
3086
+ "MacOS",
3087
+ "Microsoft Edge"
3088
+ ]
3089
+ };
3090
+ const LINUX_PATH = {
3091
+ chrome: "/opt/google/chrome/chrome",
3092
+ msedge: "/opt/microsoft/msedge/msedge"
2938
3093
  };
2939
- function missingExecutable(error) {
2940
- const message = error instanceof Error ? error.message : String(error);
2941
- return /ENOENT|doesn.t exist|Executable doesn't exist|not found/i.test(message);
3094
+ const WIN_SUFFIX = {
3095
+ chrome: "Google\\Chrome\\Application\\chrome.exe",
3096
+ msedge: "Microsoft\\Edge\\Application\\msedge.exe"
3097
+ };
3098
+ const MAC_BUNDLE = {
3099
+ chrome: {
3100
+ id: "com.google.Chrome",
3101
+ binary: "Google Chrome"
3102
+ },
3103
+ msedge: {
3104
+ id: "com.microsoft.edgemac",
3105
+ binary: "Microsoft Edge"
3106
+ }
3107
+ };
3108
+ const LINUX_NAMES = {
3109
+ chrome: ["google-chrome-stable", "google-chrome"],
3110
+ msedge: ["microsoft-edge-stable", "microsoft-edge"]
3111
+ };
3112
+ const WIN_EXE = {
3113
+ chrome: "chrome.exe",
3114
+ msedge: "msedge.exe"
3115
+ };
3116
+ const TITLE = {
3117
+ chrome: "Google Chrome",
3118
+ msedge: "Microsoft Edge"
3119
+ };
3120
+ const DISCOVER_MS = 2e3;
3121
+ /** The fixed paths checked for `browser` on `env`, in the order they are tried. */
3122
+ function candidatePaths(browser, env) {
3123
+ if (env.platform === "darwin") return ["/Applications", posix.join(env.home, "Applications")].map((root) => posix.join(root, ...MAC_APP[browser]));
3124
+ if (env.platform === "linux") return [LINUX_PATH[browser]];
3125
+ if (env.platform !== "win32") return [];
3126
+ const drive = env.env.HOMEDRIVE;
3127
+ const roots = [
3128
+ env.env.LOCALAPPDATA,
3129
+ env.env.PROGRAMFILES,
3130
+ env.env["PROGRAMFILES(X86)"],
3131
+ typeof drive === "string" && drive !== "" ? `${drive}\\Program Files` : void 0,
3132
+ typeof drive === "string" && drive !== "" ? `${drive}\\Program Files (x86)` : void 0
3133
+ ];
3134
+ const suffix = WIN_SUFFIX[browser];
3135
+ const paths = [];
3136
+ for (const root of roots) {
3137
+ if (root === void 0 || root === "") continue;
3138
+ paths.push(win32.join(root, suffix));
3139
+ }
3140
+ return paths;
2942
3141
  }
2943
3142
  /**
3143
+ * `configured` is used as given: a path that cannot be run is a failure, and
3144
+ * nothing else is searched. `~` expands to `env.home`.
3145
+ */
3146
+ function checkConfigured(configured, env) {
3147
+ const path = expandHome(configured, env.home, env.platform);
3148
+ if (!(env.platform === "win32" ? win32 : posix).isAbsolute(path)) return {
3149
+ ok: false,
3150
+ path,
3151
+ code: "not-absolute"
3152
+ };
3153
+ const access = env.access(path);
3154
+ if (access !== "ok") return {
3155
+ ok: false,
3156
+ path,
3157
+ code: access.code
3158
+ };
3159
+ return {
3160
+ ok: true,
3161
+ path
3162
+ };
3163
+ }
3164
+ /** Failure for a path the configuration names. */
3165
+ function configuredPathError(path, code) {
3166
+ if (code === "not-absolute") return /* @__PURE__ */ new Error(`The configured browser path must be absolute: ${path}`);
3167
+ return /* @__PURE__ */ new Error(`The configured browser path cannot be run (${code}): ${path}`);
3168
+ }
3169
+ /** Failure listing every path that was tried. */
3170
+ function browserNotFoundError(browser, missing) {
3171
+ const tried = missing.map((item) => `${item.path} (${item.reason})`).join("; ");
3172
+ const detail = tried === "" ? "" : ` Tried: ${tried}.`;
3173
+ return /* @__PURE__ */ new Error(`${TITLE[browser]} not found.${detail} Install it, or set the browser's path in the configuration.`);
3174
+ }
3175
+ /**
3176
+ * The path to launch. A configured path is checked and never replaced.
3177
+ * `chromium` without one returns undefined, so Playwright uses its own build.
3178
+ * An installed browser that cannot be found throws.
3179
+ */
3180
+ async function resolveExecutable(browser, configured, env, signal) {
3181
+ if (configured !== void 0 && configured !== "") {
3182
+ const checked = checkConfigured(configured, env);
3183
+ if (!checked.ok) throw configuredPathError(checked.path, checked.code);
3184
+ return checked.path;
3185
+ }
3186
+ if (browser === "chromium") return void 0;
3187
+ const located = await locateInstalled(browser, env, signal);
3188
+ if ("found" in located) return located.found;
3189
+ throw browserNotFoundError(browser, located.missing);
3190
+ }
3191
+ /**
3192
+ * Where `browser` is installed when the configuration names no path.
3193
+ * Fixed candidates first, then `env.discover`.
3194
+ */
3195
+ async function locateInstalled(browser, env, signal) {
3196
+ const missing = [];
3197
+ const seen = /* @__PURE__ */ new Set();
3198
+ const visit = (path) => {
3199
+ if (seen.has(path)) return void 0;
3200
+ seen.add(path);
3201
+ const access = env.access(path);
3202
+ if (access === "ok") return path;
3203
+ missing.push({
3204
+ path,
3205
+ reason: reasonForCode(access.code)
3206
+ });
3207
+ };
3208
+ for (const path of candidatePaths(browser, env)) {
3209
+ const found = visit(path);
3210
+ if (found !== void 0) return { found };
3211
+ }
3212
+ if (signal?.aborted === true) return { missing };
3213
+ for (const path of await env.discover(browser, signal)) {
3214
+ const found = visit(path);
3215
+ if (found !== void 0) return { found };
3216
+ }
3217
+ return { missing };
3218
+ }
3219
+ /** `path` is Playwright's Chromium. Missing or not executable is `missing`. */
3220
+ function locateBundled(path, access) {
3221
+ const result = access(path);
3222
+ if (result === "ok") return { found: path };
3223
+ return { missing: [{
3224
+ path,
3225
+ reason: reasonForCode(result.code)
3226
+ }] };
3227
+ }
3228
+ /** This machine: fixed paths, then Spotlight, App Paths, or `PATH`. */
3229
+ function hostLocateEnv() {
3230
+ const env = {
3231
+ platform: process.platform,
3232
+ env: process.env,
3233
+ home: homedir(),
3234
+ access: accessFile,
3235
+ discover: (browser, signal) => discoverInstalled(browser, env, signal)
3236
+ };
3237
+ return env;
3238
+ }
3239
+ /** A `.app` bundle path reported by Spotlight, turned into its executable. */
3240
+ function executableInApp(reported, binary) {
3241
+ const at = reported.indexOf(".app");
3242
+ if (at < 0) return void 0;
3243
+ return posix.join(reported.slice(0, at + 4), "Contents", "MacOS", binary);
3244
+ }
3245
+ /** The default value from `reg query ... /ve` output. */
3246
+ function regValue(stdout) {
3247
+ for (const line of stdout.split(/\r?\n/)) {
3248
+ const value = /\bREG_(?:EXPAND_)?SZ\s+(\S.*?)\s*$/.exec(line)?.[1]?.trim();
3249
+ if (value !== void 0 && value !== "") return value;
3250
+ }
3251
+ }
3252
+ /** Text for an errno from {@link LocateEnv.access}. */
3253
+ function reasonForCode(code) {
3254
+ if (code === "ENOENT" || code === "ENOTDIR") return "does not exist";
3255
+ if (code === "EISDIR") return "is a directory";
3256
+ return `permission denied: ${code}`;
3257
+ }
3258
+ function expandHome(configured, home, platform) {
3259
+ const slash = platform === "win32" ? win32 : posix;
3260
+ if (configured === "~") return home;
3261
+ if (configured.startsWith("~/") || configured.startsWith("~\\")) return slash.join(home, configured.slice(2));
3262
+ return configured;
3263
+ }
3264
+ function accessFile(path) {
3265
+ try {
3266
+ if (statSync(path).isDirectory()) return { code: "EISDIR" };
3267
+ accessSync(path, constants.X_OK);
3268
+ return "ok";
3269
+ } catch (error) {
3270
+ return { code: error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "EACCES" };
3271
+ }
3272
+ }
3273
+ async function discoverInstalled(browser, env, signal) {
3274
+ try {
3275
+ if (env.platform === "darwin") return await discoverMac(browser, signal);
3276
+ if (env.platform === "win32") return await discoverWindows(browser, signal);
3277
+ if (env.platform === "linux") return discoverLinux(browser, env);
3278
+ } catch {}
3279
+ return [];
3280
+ }
3281
+ async function discoverMac(browser, signal) {
3282
+ const bundle = MAC_BUNDLE[browser];
3283
+ const stdout = await command("mdfind", [`kMDItemCFBundleIdentifier == '${bundle.id}'`], signal);
3284
+ const found = [];
3285
+ for (const line of stdout.split(/\r?\n/)) {
3286
+ const trimmed = line.trim();
3287
+ if (trimmed === "") continue;
3288
+ const executable = executableInApp(trimmed, bundle.binary);
3289
+ if (executable !== void 0) found.push(executable);
3290
+ }
3291
+ return found;
3292
+ }
3293
+ async function discoverWindows(browser, signal) {
3294
+ const exe = WIN_EXE[browser];
3295
+ const found = [];
3296
+ for (const hive of ["HKCU", "HKLM"]) {
3297
+ const value = regValue(await command("reg", [
3298
+ "query",
3299
+ `${hive}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exe}`,
3300
+ "/ve"
3301
+ ], signal));
3302
+ if (value !== void 0) found.push(value);
3303
+ }
3304
+ return found;
3305
+ }
3306
+ function discoverLinux(browser, env) {
3307
+ const entries = (env.env.PATH ?? "").split(posix.delimiter);
3308
+ const found = [];
3309
+ for (const name of LINUX_NAMES[browser]) for (const dir of entries) {
3310
+ if (dir === "") continue;
3311
+ const candidate = posix.join(dir, name);
3312
+ if (env.access(candidate) !== "ok") continue;
3313
+ try {
3314
+ found.push(realpathSync(candidate));
3315
+ } catch {
3316
+ found.push(candidate);
3317
+ }
3318
+ }
3319
+ return found;
3320
+ }
3321
+ function command(file, args, signal) {
3322
+ if (signal?.aborted === true) return Promise.resolve("");
3323
+ const timer = AbortSignal.timeout(DISCOVER_MS);
3324
+ const stop = signal === void 0 ? timer : AbortSignal.any([signal, timer]);
3325
+ return new Promise((resolve) => {
3326
+ execFile(file, args, {
3327
+ windowsHide: true,
3328
+ signal: stop
3329
+ }, (error, stdout) => {
3330
+ resolve(error ? "" : String(stdout));
3331
+ });
3332
+ });
3333
+ }
3334
+ //#endregion
3335
+ //#region lib/types/service/browser.js
3336
+ /**
2944
3337
  * Playwright appends `--no-sandbox` unless this is true, and branded Chrome
2945
3338
  * then warns that stability and security will suffer. An installed browser
2946
3339
  * sandboxes on every platform. Playwright's own Chromium does too, except on
@@ -2950,34 +3343,45 @@ function chromiumSandboxEnabled(browser, platform = process.platform) {
2950
3343
  return platform !== "linux" || browser !== "chromium";
2951
3344
  }
2952
3345
  /**
2953
- * One persistent context for the whole process. `chromium` omits `channel`
2954
- * so Playwright uses its own browser; the others use a local install.
2955
- * A missing browser is reported and not downloaded.
3346
+ * One persistent context for the whole process. An installed browser is located
3347
+ * here and passed as `executablePath`; `channel` names which install that was.
3348
+ * `chromium` omits `channel` so Playwright uses its own browser unless a path
3349
+ * is configured. A missing browser is reported and not downloaded. A launch
3350
+ * that fails after the executable was found is reported as Playwright said.
2956
3351
  */
2957
- async function launchBrowser(options) {
3352
+ async function launchBrowser(options, env = hostLocateEnv()) {
2958
3353
  await mkdir(options.profileDir, { recursive: true });
3354
+ const executablePath = await resolveExecutable(options.browser, options.executablePath, env);
3355
+ return chromium.launchPersistentContext(options.profileDir, {
3356
+ headless: options.headless,
3357
+ acceptDownloads: true,
3358
+ chromiumSandbox: chromiumSandboxEnabled(options.browser),
3359
+ ...options.headless ? {} : {
3360
+ viewport: null,
3361
+ ignoreDefaultArgs: ["--disable-extensions"],
3362
+ args: [
3363
+ "--enable-unsafe-extension-debugging",
3364
+ "--keep-alive-for-test",
3365
+ "--disable-blink-features=AutomationControlled",
3366
+ "--test-type"
3367
+ ]
3368
+ },
3369
+ ...options.browser === "chromium" ? {} : { channel: options.browser },
3370
+ ...executablePath !== void 0 ? { executablePath } : {}
3371
+ });
3372
+ }
3373
+ /** Playwright's own Chromium, or why that file cannot be run. */
3374
+ function bundledChromiumLocation(access) {
3375
+ let path;
2959
3376
  try {
2960
- return await chromium.launchPersistentContext(options.profileDir, {
2961
- headless: options.headless,
2962
- acceptDownloads: true,
2963
- chromiumSandbox: chromiumSandboxEnabled(options.browser),
2964
- ...options.headless ? {} : {
2965
- viewport: null,
2966
- ignoreDefaultArgs: ["--disable-extensions"],
2967
- args: [
2968
- "--enable-unsafe-extension-debugging",
2969
- "--keep-alive-for-test",
2970
- "--disable-blink-features=AutomationControlled",
2971
- "--test-type"
2972
- ]
2973
- },
2974
- ...options.browser === "chromium" ? {} : { channel: options.browser },
2975
- ...options.executablePath !== void 0 ? { executablePath: options.executablePath } : {}
2976
- });
3377
+ path = chromium.executablePath();
2977
3378
  } catch (error) {
2978
- if (missingExecutable(error) || options.executablePath !== void 0) throw new Error(MISSING[options.browser]);
2979
- throw error;
3379
+ return { missing: [{
3380
+ path: "chromium",
3381
+ reason: error instanceof Error ? error.message : "does not exist"
3382
+ }] };
2980
3383
  }
3384
+ return locateBundled(path, access);
2981
3385
  }
2982
3386
  /**
2983
3387
  * Process id of the browser's main process. Playwright gives none for a
@@ -3040,6 +3444,15 @@ var Control = class {
3040
3444
  tabs = /* @__PURE__ */ new Map();
3041
3445
  since = /* @__PURE__ */ new Map();
3042
3446
  named = /* @__PURE__ */ new Map();
3447
+ /**
3448
+ * A handover is waiting, or the person holds a tab. The browser is not
3449
+ * restarted out from under either.
3450
+ */
3451
+ busy() {
3452
+ if (this.holding.size > 0) return true;
3453
+ for (const who of this.who.values()) if (who === "person") return true;
3454
+ return false;
3455
+ }
3043
3456
  /** Whether the person holds any tab, or a handover is waiting. */
3044
3457
  of(owner) {
3045
3458
  return this.who.get(owner) ?? "model";
@@ -3684,6 +4097,8 @@ async function dispatch(page, op, snapshot, signal) {
3684
4097
  //#region lib/types/service/evaluate.js
3685
4098
  /** How long requests the function started are still collected after it returns. */
3686
4099
  const SETTLE_MS$1 = 50;
4100
+ /** A debugger call or a detach the page no longer answers. */
4101
+ const CALL_MS$2 = 15e3;
3687
4102
  /**
3688
4103
  * Run the expression in the page and collect write requests.
3689
4104
  * A request is from this call when its initiator stack, including parents,
@@ -3693,7 +4108,8 @@ const SETTLE_MS$1 = 50;
3693
4108
  */
3694
4109
  async function evaluateMarked(page, source, target, callId, signal, dialog) {
3695
4110
  const marker = `mh-evaluate-${callId}-${randomUUID().slice(0, 8)}`;
3696
- const client = await page.context().newCDPSession(page);
4111
+ const limit = capped(signal, CALL_MS$2);
4112
+ const client = await untilAborted(page.context().newCDPSession(page), limit);
3697
4113
  const notes = /* @__PURE__ */ new Map();
3698
4114
  const onRequest = (event) => {
3699
4115
  const method = event.request?.method?.toUpperCase();
@@ -3713,10 +4129,10 @@ async function evaluateMarked(page, source, target, callId, signal, dialog) {
3713
4129
  const stop = whenAborted(signal);
3714
4130
  let dialogOpen = false;
3715
4131
  try {
3716
- await client.send("Network.enable");
3717
- await client.send("Debugger.enable");
3718
- await client.send("Debugger.setSkipAllPauses", { skip: true });
3719
- await client.send("Debugger.setAsyncCallStackDepth", { maxDepth: 32 });
4132
+ await untilAborted(client.send("Network.enable"), limit);
4133
+ await untilAborted(client.send("Debugger.enable"), limit);
4134
+ await untilAborted(client.send("Debugger.setSkipAllPauses", { skip: true }), limit);
4135
+ await untilAborted(client.send("Debugger.setAsyncCallStackDepth", { maxDepth: 32 }), limit);
3720
4136
  const evaluation = (target === void 0 ? page.evaluate(runSource, {
3721
4137
  expr: source,
3722
4138
  marker
@@ -3768,7 +4184,7 @@ async function evaluateMarked(page, source, target, callId, signal, dialog) {
3768
4184
  stop.dispose();
3769
4185
  client.off("Network.requestWillBeSent", onRequest);
3770
4186
  const detached = client.detach().catch(() => void 0);
3771
- if (!dialogOpen) await detached;
4187
+ if (!dialogOpen) await untilAborted(detached, capped(signal, CALL_MS$2)).catch(() => void 0);
3772
4188
  }
3773
4189
  }
3774
4190
  /** One line per group: `POST https://site × 2 (this call)`. */
@@ -4166,6 +4582,8 @@ const REPLIED = "继续";
4166
4582
  //#region lib/types/service/extension.js
4167
4583
  const PACKAGE = "@mind-harness/mh-browser";
4168
4584
  const WORKER_MS = 15e3;
4585
+ /** A service-worker call that does not answer. The caller's signal can end it sooner. */
4586
+ const CALL_MS$1 = 15e3;
4169
4587
  /**
4170
4588
  * Directory of the unpacked extension shipped with this package.
4171
4589
  * Walks up from this module: sources live in `src/`, the bundle in `lib/`.
@@ -4205,8 +4623,9 @@ var Extension = class Extension {
4205
4623
  return `chrome-extension://${this.id}/${name}`;
4206
4624
  }
4207
4625
  /** `chrome.tabs.create` in the named window. Returns the extension's tab id. */
4208
- async createTab(windowId, url) {
4209
- return (await this.worker()).evaluate(async (arg) => {
4626
+ async createTab(windowId, url, signal) {
4627
+ const worker = await this.worker(signal);
4628
+ return this.answer(worker.evaluate(async (arg) => {
4210
4629
  const tab = await globalThis.chrome.tabs.create({
4211
4630
  windowId: arg.windowId,
4212
4631
  url: arg.url,
@@ -4217,23 +4636,25 @@ var Extension = class Extension {
4217
4636
  }, {
4218
4637
  windowId,
4219
4638
  url
4220
- });
4639
+ }), signal);
4221
4640
  }
4222
- async tabIdsIn(windowId) {
4223
- return (await this.worker()).evaluate(async (target) => {
4641
+ async tabIdsIn(windowId, signal) {
4642
+ const worker = await this.worker(signal);
4643
+ return this.answer(worker.evaluate(async (target) => {
4224
4644
  return (await globalThis.chrome.tabs.query({ windowId: target })).flatMap((tab) => tab.id === void 0 ? [] : [tab.id]);
4225
- }, windowId);
4645
+ }, windowId), signal);
4226
4646
  }
4227
- async activate(tabId) {
4228
- await (await this.worker()).evaluate(async (id) => {
4647
+ async activate(tabId, signal) {
4648
+ const worker = await this.worker(signal);
4649
+ await this.answer(worker.evaluate(async (id) => {
4229
4650
  await globalThis.chrome.tabs.update(id, { active: true });
4230
- }, tabId);
4651
+ }, tabId), signal);
4231
4652
  }
4232
- async group(tabId, target) {
4233
- const worker = await this.worker();
4653
+ async group(tabId, target, signal) {
4654
+ const worker = await this.worker(signal);
4234
4655
  if ("groupId" in target) {
4235
4656
  const groupId = target.groupId;
4236
- return worker.evaluate(async (arg) => {
4657
+ return this.answer(worker.evaluate(async (arg) => {
4237
4658
  return globalThis.chrome.tabs.group({
4238
4659
  tabIds: [arg.tabId],
4239
4660
  groupId: arg.groupId
@@ -4241,10 +4662,10 @@ var Extension = class Extension {
4241
4662
  }, {
4242
4663
  tabId,
4243
4664
  groupId
4244
- });
4665
+ }), signal);
4245
4666
  }
4246
4667
  const windowId = target.windowId;
4247
- return worker.evaluate(async (arg) => {
4668
+ return this.answer(worker.evaluate(async (arg) => {
4248
4669
  return globalThis.chrome.tabs.group({
4249
4670
  tabIds: [arg.tabId],
4250
4671
  createProperties: { windowId: arg.windowId }
@@ -4252,10 +4673,11 @@ var Extension = class Extension {
4252
4673
  }, {
4253
4674
  tabId,
4254
4675
  windowId
4255
- });
4676
+ }), signal);
4256
4677
  }
4257
- async styleGroup(groupId, title, color) {
4258
- await (await this.worker()).evaluate(async (arg) => {
4678
+ async styleGroup(groupId, title, color, signal) {
4679
+ const worker = await this.worker(signal);
4680
+ await this.answer(worker.evaluate(async (arg) => {
4259
4681
  await globalThis.chrome.tabGroups.update(arg.groupId, {
4260
4682
  title: arg.title,
4261
4683
  color: arg.color
@@ -4264,17 +4686,21 @@ var Extension = class Extension {
4264
4686
  groupId,
4265
4687
  title,
4266
4688
  color
4267
- });
4689
+ }), signal);
4268
4690
  }
4269
- async ungroup(tabId) {
4270
- await (await this.worker()).evaluate(async (id) => {
4691
+ async ungroup(tabId, signal) {
4692
+ const worker = await this.worker(signal);
4693
+ await this.answer(worker.evaluate(async (id) => {
4271
4694
  await globalThis.chrome.tabs.ungroup(id);
4272
- }, tabId);
4695
+ }, tabId), signal);
4696
+ }
4697
+ answer(pending, signal) {
4698
+ return untilAborted(pending, capped(signal, CALL_MS$1));
4273
4699
  }
4274
- async worker() {
4700
+ async worker(signal) {
4275
4701
  const current = this.workerPage;
4276
4702
  if (current !== void 0 && this.context.serviceWorkers().includes(current)) return current;
4277
- const worker = await findWorker(this.context, this.id);
4703
+ const worker = await untilAborted(findWorker(this.context, this.id), capped(signal, CALL_MS$1));
4278
4704
  this.workerPage = worker;
4279
4705
  return worker;
4280
4706
  }
@@ -4300,6 +4726,10 @@ async function findWorker(context, id, timeoutMs = WORKER_MS) {
4300
4726
  const TAB_CAP = 8;
4301
4727
  /** How long a new window or tab may take to appear. */
4302
4728
  const TARGET_MS = 5e3;
4729
+ /** A CDP or extension call that does not answer. The caller's signal can end it sooner. */
4730
+ const CALL_MS = 15e3;
4731
+ /** Closing a page the renderer no longer answers. */
4732
+ const CLOSE_MS = 5e3;
4303
4733
  const WORLD = "mh-browser";
4304
4734
  const BINDING = "mhBrowserInput";
4305
4735
  const LISTENER = `(() => {
@@ -4521,10 +4951,10 @@ var Windows = class {
4521
4951
  return open.reduce((best, tab) => tab.touchedAt >= best.touchedAt ? tab : best);
4522
4952
  }
4523
4953
  /** Make this tab the current one in its window. Does not raise the browser. */
4524
- async activate(tab) {
4954
+ async activate(tab, signal) {
4525
4955
  const extension = this.extension;
4526
4956
  if (extension === void 0 || tab.chromeTabId === void 0) return;
4527
- await extension.activate(tab.chromeTabId);
4957
+ await extension.activate(tab.chromeTabId, signal);
4528
4958
  }
4529
4959
  /** Group title for the person. Empty until a title is known. */
4530
4960
  setTitle(owner, title) {
@@ -4551,54 +4981,70 @@ var Windows = class {
4551
4981
  * use the extension. Without a window, later pages use `window.open` from
4552
4982
  * an isolated world so they stay in that window.
4553
4983
  */
4554
- openBlank(owner) {
4984
+ openBlank(owner, signal) {
4555
4985
  this.pendingCreates.set(owner, (this.pendingCreates.get(owner) ?? 0) + 1);
4556
- const create = () => this.createTab(owner);
4557
- const run = this.creating.then(create, create).finally(() => {
4986
+ const queued = enqueue(this.creating, () => this.createTab(owner, signal), signal);
4987
+ const run = queued.run.finally(() => {
4558
4988
  const left = (this.pendingCreates.get(owner) ?? 1) - 1;
4559
4989
  if (left <= 0) this.pendingCreates.delete(owner);
4560
4990
  else this.pendingCreates.set(owner, left);
4561
4991
  });
4562
- this.creating = run.catch(() => void 0);
4992
+ this.creating = queued.tail;
4563
4993
  return run;
4564
4994
  }
4565
- async createTab(owner) {
4995
+ async createTab(owner, signal) {
4566
4996
  const extension = this.extension;
4567
4997
  if (extension === void 0) {
4568
- const opener = await this.openerFor(owner);
4569
- const page = await this.openTabIn(opener);
4570
- return this.keep(owner, page, void 0);
4998
+ const opener = await this.openerFor(owner, signal);
4999
+ const page = await this.openTabIn(opener, signal);
5000
+ return this.keep(owner, page, void 0, void 0, signal);
4571
5001
  }
4572
5002
  const open = this.owned(owner);
4573
5003
  if (open.length === 0) {
4574
- const page = await this.openWindow();
4575
- const windowId = await this.windowIdOf(page);
4576
- const chromeTabId = await this.extensionTabIn(extension, windowId);
4577
- const tab = await this.keep(owner, page, chromeTabId);
4578
- this.groupTab(tab);
5004
+ const page = await this.openWindow(signal);
5005
+ const windowId = await this.windowIdOf(page, signal);
5006
+ const chromeTabId = await this.extensionTabIn(extension, windowId, signal);
5007
+ const tab = await this.keep(owner, page, chromeTabId, void 0, signal);
5008
+ this.groupTab(tab, signal);
4579
5009
  return tab;
4580
5010
  }
4581
5011
  const anchor = open.reduce((best, tab) => tab.touchedAt >= best.touchedAt ? tab : best);
4582
- const opened = await this.openTabViaExtension(anchor.windowId);
4583
- const tab = await this.keep(owner, opened.page, opened.chromeTabId, anchor.windowId);
4584
- this.groupTab(tab);
5012
+ const opened = await this.openTabViaExtension(anchor.windowId, signal);
5013
+ const tab = await this.keep(owner, opened.page, opened.chromeTabId, anchor.windowId, signal);
5014
+ this.groupTab(tab, signal);
4585
5015
  return tab;
4586
5016
  }
4587
- async keep(owner, page, chromeTabId, windowId) {
4588
- const tab = {
4589
- id: await this.allocate(),
4590
- owner,
4591
- page,
4592
- targetId: await this.targetIdOf(page),
4593
- windowId: windowId ?? await this.windowIdOf(page),
4594
- chromeTabId,
4595
- url: page.url(),
4596
- touchedAt: Date.now(),
4597
- ...idleTab()
4598
- };
4599
- this.remember(tab);
4600
- await this.arm(page, owner, tab.id);
4601
- return tab;
5017
+ async keep(owner, page, chromeTabId, windowId, signal) {
5018
+ let tab;
5019
+ try {
5020
+ tab = {
5021
+ id: await this.allocate(),
5022
+ owner,
5023
+ page,
5024
+ targetId: await this.targetIdOf(page, signal),
5025
+ windowId: windowId ?? await this.windowIdOf(page, signal),
5026
+ chromeTabId,
5027
+ url: page.url(),
5028
+ touchedAt: Date.now(),
5029
+ ...idleTab()
5030
+ };
5031
+ this.remember(tab);
5032
+ await this.arm(page, owner, tab.id, signal);
5033
+ return tab;
5034
+ } catch (error) {
5035
+ if (tab !== void 0 && this.byPage.get(page) === tab) await this.closePage(tab);
5036
+ else await this.shutdown(page);
5037
+ throw error;
5038
+ }
5039
+ }
5040
+ /** The page from a create, or close it when this wait is abandoned. */
5041
+ async claimedPage(pending, signal) {
5042
+ try {
5043
+ return await untilAborted(pending, capped(signal, TARGET_MS));
5044
+ } catch (error) {
5045
+ pending.then((page) => this.shutdown(page)).catch(() => void 0);
5046
+ throw error;
5047
+ }
4602
5048
  }
4603
5049
  /**
4604
5050
  * Close one tab. `note` is listed in the next `<tabs>` block. A tab the
@@ -4612,9 +5058,12 @@ var Windows = class {
4612
5058
  }
4613
5059
  await this.closePage(tab);
4614
5060
  }
4615
- async closeOwner(owner) {
4616
- for (const tab of this.owned(owner)) await this.closePage(tab);
4617
- await this.closeOpener(owner);
5061
+ async closeOwner(owner, signal) {
5062
+ for (const tab of this.owned(owner)) {
5063
+ if (signal?.aborted === true) break;
5064
+ await this.closePage(tab);
5065
+ }
5066
+ if (signal?.aborted !== true) await this.closeOpener(owner);
4618
5067
  this.closedNotes.delete(owner);
4619
5068
  for (const [id, last] of this.lastUrls) if (last.owner === owner) this.lastUrls.delete(id);
4620
5069
  this.groups.delete(owner);
@@ -4632,7 +5081,7 @@ var Windows = class {
4632
5081
  }
4633
5082
  this.serviceClose.add(tab.page);
4634
5083
  this.forget(tab);
4635
- if (!tab.page.isClosed()) await tab.page.close().catch(() => void 0);
5084
+ if (!tab.page.isClosed()) await this.shutdown(tab.page);
4636
5085
  }
4637
5086
  /** A tab leaves the registry. The window closes with its last tab. */
4638
5087
  forget(tab) {
@@ -4660,7 +5109,7 @@ var Windows = class {
4660
5109
  session?.detach().catch(() => void 0);
4661
5110
  if (opener.isClosed()) return;
4662
5111
  this.serviceClose.add(opener);
4663
- await opener.close().catch(() => void 0);
5112
+ await this.shutdown(opener);
4664
5113
  }
4665
5114
  /** Close least-recently touched idle tabs until the owner is within the cap. */
4666
5115
  async enforceCap(owner) {
@@ -4707,57 +5156,58 @@ var Windows = class {
4707
5156
  await writeCounter(this.profileDir, this.nextId);
4708
5157
  return id;
4709
5158
  }
4710
- async openerFor(owner) {
5159
+ async openerFor(owner, signal) {
4711
5160
  const existing = this.openers.get(owner);
4712
5161
  if (existing !== void 0 && !existing.isClosed()) return existing;
4713
- const opener = await this.openWindow();
5162
+ const opener = await this.openWindow(signal);
4714
5163
  this.openers.set(owner, opener);
4715
- await this.arm(opener, owner, void 0);
5164
+ await this.arm(opener, owner, void 0, signal);
4716
5165
  return opener;
4717
5166
  }
4718
- async openWindow() {
5167
+ async openWindow(signal) {
4719
5168
  const session = this.browserSession;
4720
5169
  if (session === void 0) throw new Error("browser is not running");
4721
- const created = await this.createWindowTarget(session);
4722
- return this.waitForTarget(created.targetId);
5170
+ const created = await this.createWindowTarget(session, signal);
5171
+ const pending = this.waitForTarget(created.targetId);
5172
+ return this.claimedPage(pending, signal);
4723
5173
  }
4724
5174
  /** Headed windows pass `focus: false`. A browser that rejects it is opened without the flag. */
4725
- async createWindowTarget(session) {
5175
+ async createWindowTarget(session, signal) {
4726
5176
  const base = {
4727
5177
  url: "about:blank",
4728
5178
  newWindow: true
4729
5179
  };
4730
- if (!this.headed) return await session.send("Target.createTarget", base);
5180
+ if (!this.headed) return this.cdp(session, "Target.createTarget", base, signal);
4731
5181
  try {
4732
- return await session.send("Target.createTarget", {
5182
+ return await this.cdp(session, "Target.createTarget", {
4733
5183
  ...base,
4734
5184
  focus: false
4735
- });
4736
- } catch {
4737
- return await session.send("Target.createTarget", base);
5185
+ }, signal);
5186
+ } catch (error) {
5187
+ if (signal?.aborted === true || isInterrupted(error)) throw error;
5188
+ return this.cdp(session, "Target.createTarget", base, signal);
4738
5189
  }
4739
5190
  }
4740
- async openTabIn(anchor) {
4741
- const context = this.context;
4742
- if (context === void 0) throw new Error("browser is not running");
5191
+ async openTabIn(anchor, signal) {
5192
+ if (this.context === void 0) throw new Error("browser is not running");
4743
5193
  const claim = this.claimBlank();
4744
5194
  try {
4745
- const session = await context.newCDPSession(anchor);
5195
+ const session = await this.pageSession(anchor, signal);
4746
5196
  try {
4747
- const tree = await session.send("Page.getFrameTree");
4748
- const world = await session.send("Page.createIsolatedWorld", {
5197
+ const tree = await this.cdp(session, "Page.getFrameTree", void 0, signal);
5198
+ const world = await this.cdp(session, "Page.createIsolatedWorld", {
4749
5199
  frameId: tree.frameTree.frame.id,
4750
5200
  worldName: WORLD,
4751
5201
  grantUniveralAccess: true
4752
- });
4753
- await session.send("Runtime.evaluate", {
5202
+ }, signal);
5203
+ await this.cdp(session, "Runtime.evaluate", {
4754
5204
  expression: `window.open(${JSON.stringify(claim.url)},'_blank','noopener,noreferrer')`,
4755
5205
  contextId: world.executionContextId
4756
- });
5206
+ }, signal);
4757
5207
  } finally {
4758
- await session.detach().catch(() => void 0);
5208
+ await this.shutdownSession(session);
4759
5209
  }
4760
- return await claim.page;
5210
+ return await this.claimedPage(claim.page, signal);
4761
5211
  } finally {
4762
5212
  claim.drop();
4763
5213
  }
@@ -4800,22 +5250,23 @@ var Windows = class {
4800
5250
  };
4801
5251
  }
4802
5252
  /** The extension's id for the only tab in a window just opened. */
4803
- async extensionTabIn(extension, windowId) {
5253
+ async extensionTabIn(extension, windowId, signal) {
4804
5254
  for (let attempt = 0; attempt < 5; attempt += 1) {
4805
- const id = (await extension.tabIdsIn(windowId))[0];
5255
+ if (signal?.aborted === true) return void 0;
5256
+ const id = (await extension.tabIdsIn(windowId, signal))[0];
4806
5257
  if (id !== void 0) return id;
4807
- await new Promise((resolve) => setTimeout(resolve, 100));
5258
+ await untilAborted(new Promise((resolve) => setTimeout(resolve, 100)), signal).catch(() => void 0);
4808
5259
  }
4809
5260
  }
4810
5261
  /** Ask the extension to open a blank tab in `windowId`, and claim it. */
4811
- async openTabViaExtension(windowId) {
5262
+ async openTabViaExtension(windowId, signal) {
4812
5263
  const extension = this.extension;
4813
5264
  if (extension === void 0) throw new Error("browser is not running");
4814
5265
  const claim = this.claimBlank();
4815
5266
  try {
4816
- const chromeTabId = await extension.createTab(windowId, claim.url);
5267
+ const chromeTabId = await extension.createTab(windowId, claim.url, signal);
4817
5268
  return {
4818
- page: await claim.page,
5269
+ page: await this.claimedPage(claim.page, signal),
4819
5270
  chromeTabId
4820
5271
  };
4821
5272
  } finally {
@@ -4823,28 +5274,29 @@ var Windows = class {
4823
5274
  }
4824
5275
  }
4825
5276
  /** Put a Session's tab in its group. The person's window is not a Session and stays ungrouped. */
4826
- async groupTab(tab) {
5277
+ async groupTab(tab, signal) {
4827
5278
  const extension = this.extension;
4828
5279
  const chromeTabId = tab.chromeTabId;
4829
5280
  if (extension === void 0 || chromeTabId === void 0 || tab.owner === "person") return;
4830
5281
  try {
4831
5282
  const known = this.groups.get(tab.owner);
4832
- if (known === void 0) this.groups.set(tab.owner, await extension.group(chromeTabId, { windowId: tab.windowId }));
5283
+ if (known === void 0) this.groups.set(tab.owner, await extension.group(chromeTabId, { windowId: tab.windowId }, signal));
4833
5284
  else try {
4834
- await extension.group(chromeTabId, { groupId: known });
4835
- } catch {
4836
- this.groups.set(tab.owner, await extension.group(chromeTabId, { windowId: tab.windowId }));
5285
+ await extension.group(chromeTabId, { groupId: known }, signal);
5286
+ } catch (error) {
5287
+ if (isInterrupted(error)) return;
5288
+ this.groups.set(tab.owner, await extension.group(chromeTabId, { windowId: tab.windowId }, signal));
4837
5289
  }
4838
- await this.applyGroupStyle(tab.owner);
5290
+ await this.applyGroupStyle(tab.owner, signal);
4839
5291
  } catch {}
4840
5292
  }
4841
- async applyGroupStyle(owner) {
5293
+ async applyGroupStyle(owner, signal) {
4842
5294
  const extension = this.extension;
4843
5295
  const groupId = this.groups.get(owner);
4844
5296
  if (extension === void 0 || groupId === void 0) return;
4845
5297
  const title = this.titles.get(owner) ?? "";
4846
5298
  const waiting = this.waiting.has(owner);
4847
- await extension.styleGroup(groupId, waiting ? `${WAITING_TITLE}${title}` : title, waiting ? "red" : "blue");
5299
+ await extension.styleGroup(groupId, waiting ? `${WAITING_TITLE}${title}` : title, waiting ? "red" : "blue", signal);
4848
5300
  }
4849
5301
  async ingest(page) {
4850
5302
  if (this.byPage.has(page)) return;
@@ -4866,6 +5318,10 @@ var Windows = class {
4866
5318
  slot.accept(page);
4867
5319
  return;
4868
5320
  }
5321
+ if (page.url().startsWith("about:blank#mh-")) {
5322
+ await this.shutdown(page);
5323
+ return;
5324
+ }
4869
5325
  const opener = await page.opener().catch(() => null);
4870
5326
  const fromOpener = opener === null ? void 0 : this.byPage.get(opener);
4871
5327
  if (fromOpener !== void 0) {
@@ -4907,10 +5363,9 @@ var Windows = class {
4907
5363
  * or a close the service did not start counts as well. A page the content
4908
5364
  * tab opened itself is not counted for its first navigation or its close.
4909
5365
  */
4910
- async arm(page, owner, tab) {
5366
+ async arm(page, owner, tab, signal) {
4911
5367
  if (this.armed.has(page)) return;
4912
- const context = this.context;
4913
- if (context === void 0) return;
5368
+ if (this.context === void 0) return;
4914
5369
  this.armed.add(page);
4915
5370
  page.on("close", () => {
4916
5371
  const live = this.context;
@@ -4928,19 +5383,19 @@ var Windows = class {
4928
5383
  });
4929
5384
  let session;
4930
5385
  try {
4931
- session = await context.newCDPSession(page);
5386
+ session = await this.pageSession(page, signal);
4932
5387
  this.sessions.set(page, session);
4933
- await session.send("Runtime.enable");
4934
- await session.send("Page.enable");
4935
- await session.send("Runtime.addBinding", {
5388
+ await this.cdp(session, "Runtime.enable", void 0, signal);
5389
+ await this.cdp(session, "Page.enable", void 0, signal);
5390
+ await this.cdp(session, "Runtime.addBinding", {
4936
5391
  name: BINDING,
4937
5392
  executionContextName: WORLD
4938
- });
4939
- await session.send("Page.addScriptToEvaluateOnNewDocument", {
5393
+ }, signal);
5394
+ await this.cdp(session, "Page.addScriptToEvaluateOnNewDocument", {
4940
5395
  source: LISTENER,
4941
5396
  worldName: WORLD,
4942
5397
  runImmediately: true
4943
- });
5398
+ }, signal);
4944
5399
  } catch (error) {
4945
5400
  if (closedTarget(error)) {
4946
5401
  const known = this.byPage.get(page);
@@ -4997,21 +5452,35 @@ var Windows = class {
4997
5452
  this.sessions.delete(page);
4998
5453
  session?.detach().catch(() => void 0);
4999
5454
  }
5000
- async targetIdOf(page) {
5001
- const context = this.context;
5002
- if (context === void 0) throw new Error("browser is not running");
5003
- const session = await context.newCDPSession(page);
5455
+ async targetIdOf(page, signal) {
5456
+ const session = await this.pageSession(page, signal);
5004
5457
  try {
5005
- return (await session.send("Target.getTargetInfo")).targetInfo.targetId;
5458
+ return (await this.cdp(session, "Target.getTargetInfo", void 0, signal)).targetInfo.targetId;
5006
5459
  } finally {
5007
- await session.detach().catch(() => void 0);
5460
+ await this.shutdownSession(session);
5008
5461
  }
5009
5462
  }
5010
- async windowIdOf(page) {
5463
+ async windowIdOf(page, signal) {
5011
5464
  const session = this.browserSession;
5012
5465
  if (session === void 0) throw new Error("browser is not running");
5013
- const targetId = await this.targetIdOf(page);
5014
- return (await session.send("Browser.getWindowForTarget", { targetId })).windowId;
5466
+ const targetId = await this.targetIdOf(page, signal);
5467
+ return (await this.cdp(session, "Browser.getWindowForTarget", { targetId }, signal)).windowId;
5468
+ }
5469
+ pageSession(page, signal) {
5470
+ const context = this.context;
5471
+ if (context === void 0) return Promise.reject(/* @__PURE__ */ new Error("browser is not running"));
5472
+ return untilAborted(context.newCDPSession(page), capped(signal, CALL_MS));
5473
+ }
5474
+ cdp(session, method, params, signal) {
5475
+ const send = session.send.bind(session);
5476
+ return untilAborted(params === void 0 ? send(method) : send(method, params), capped(signal, CALL_MS));
5477
+ }
5478
+ /** Close a page that may no longer answer. A stall ends at `CLOSE_MS`. */
5479
+ shutdown(page) {
5480
+ return untilAborted(page.close(), AbortSignal.timeout(CLOSE_MS)).catch(() => void 0);
5481
+ }
5482
+ shutdownSession(session) {
5483
+ return untilAborted(session.detach(), AbortSignal.timeout(CLOSE_MS)).catch(() => void 0);
5015
5484
  }
5016
5485
  };
5017
5486
  /** Playwright's error once the page, its context, or the browser has gone. */
@@ -5104,6 +5573,10 @@ const RELAUNCH_WINDOW_MS = 6e4;
5104
5573
  const DOCUMENT_CAP = 64;
5105
5574
  const OPEN_ADDRESS_REASON = "only http and https addresses are opened";
5106
5575
  const STOP_WAIT_MS = 5e3;
5576
+ /** A page read the renderer no longer answers. The caller's signal can end it sooner. */
5577
+ const READ_MS = 15e3;
5578
+ /** Closing one Session's windows must not wait on a renderer that stopped answering. */
5579
+ const DISPOSE_MS = 3e4;
5107
5580
  const WAITING_STOPPED = "stopped while waiting";
5108
5581
  const STOPPED = Symbol("stopped");
5109
5582
  const NAVIGATED_DURING_EVALUATE = "The page navigated before the function returned.";
@@ -5119,11 +5592,24 @@ const NO_WORKSPACE_REASON = "this session has no working directory";
5119
5592
  let MhBrowserService = (() => {
5120
5593
  let _classSuper = TypertRemoteService;
5121
5594
  let _instanceExtraInitializers = [];
5595
+ let _locateFor_decorators;
5122
5596
  let _revealHandover_decorators;
5123
5597
  return class MhBrowserService extends _classSuper {
5124
5598
  static {
5125
5599
  const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
5600
+ _locateFor_decorators = [Remote];
5126
5601
  _revealHandover_decorators = [Remote];
5602
+ __esDecorate(this, null, _locateFor_decorators, {
5603
+ kind: "method",
5604
+ name: "locateFor",
5605
+ static: false,
5606
+ private: false,
5607
+ access: {
5608
+ has: (obj) => "locateFor" in obj,
5609
+ get: (obj) => obj.locateFor
5610
+ },
5611
+ metadata: _metadata
5612
+ }, null, _instanceExtraInitializers);
5127
5613
  __esDecorate(this, null, _revealHandover_decorators, {
5128
5614
  kind: "method",
5129
5615
  name: "revealHandover",
@@ -5142,13 +5628,23 @@ let MhBrowserService = (() => {
5142
5628
  value: _metadata
5143
5629
  });
5144
5630
  }
5145
- static Config = browserConfig;
5146
5631
  windows = (__runInitializers(this, _instanceExtraInitializers), new Windows());
5147
- launchOptions;
5632
+ headless;
5633
+ profileDir;
5634
+ browserName;
5635
+ executablePath;
5636
+ /** Tests replace the machine lookup. Unset uses this machine. */
5637
+ locator;
5148
5638
  context;
5149
5639
  pids = /* @__PURE__ */ new WeakMap();
5150
5640
  ready = Promise.resolve();
5151
5641
  closing = false;
5642
+ /** A browser restart was requested and has not run yet. */
5643
+ relaunchWanted = false;
5644
+ /** Counts browser and path changes. A start that sampled the latest one clears the restart. */
5645
+ configEpoch = 0;
5646
+ /** The running browser is being closed so the new configuration can start. */
5647
+ replacing = false;
5152
5648
  /** The browser is not running. The next call that needs a page starts it. */
5153
5649
  exited = true;
5154
5650
  exits = [];
@@ -5167,11 +5663,16 @@ let MhBrowserService = (() => {
5167
5663
  constructor(ctx, config) {
5168
5664
  super(ctx, "mhBrowser");
5169
5665
  this.maxBytes = config.presence.maxBytes;
5170
- this.launchOptions = {
5171
- browser: config.browser,
5172
- headless: config.headless,
5173
- profileDir: config.profileDir ?? defaultProfileDir(ctx.get("profileContext"))
5174
- };
5666
+ this.headless = config.headless;
5667
+ this.profileDir = config.profileDir ?? defaultProfileDir(ctx.get("profileContext"));
5668
+ this.browserName = config.browser;
5669
+ this.executablePath = config.executablePath ?? { get: () => void 0 };
5670
+ Object.getPrototypeOf(ctx).on("loader/volatile-update", (paths) => {
5671
+ if (!paths.some((path) => path.includes("browser") || path.includes("executablePath"))) return;
5672
+ this.configEpoch += 1;
5673
+ this.relaunchWanted = true;
5674
+ this.relaunchIfIdle();
5675
+ });
5175
5676
  this.windows.setPersonInput((owner, tab) => {
5176
5677
  const live = tab === void 0 ? void 0 : this.windows.get(owner, tab);
5177
5678
  this.control.note(owner, tab);
@@ -5184,7 +5685,7 @@ let MhBrowserService = (() => {
5184
5685
  ctx.on("session/disposed", (session) => {
5185
5686
  const owner = session.id;
5186
5687
  if (owner === void 0) return;
5187
- this.exclusive(owner, () => this.windows.closeOwner(owner)).catch((error) => {
5688
+ this.exclusive(owner, () => this.windows.closeOwner(owner, AbortSignal.timeout(DISPOSE_MS)), AbortSignal.timeout(DISPOSE_MS)).catch((error) => {
5188
5689
  ctx.logger.warn("closing the windows of %s failed: %o", owner, error);
5189
5690
  });
5190
5691
  this.book.forget(owner);
@@ -5206,22 +5707,30 @@ let MhBrowserService = (() => {
5206
5707
  }
5207
5708
  async open(owner, items, signal, options) {
5208
5709
  const reveal = options?.reveal === true;
5209
- return this.exclusive(owner, async () => {
5210
- await this.up(owner === PERSON_OWNER);
5211
- const views = [];
5212
- for (const item of items) {
5213
- if (signal?.aborted) {
5214
- views.push({
5215
- url: item.url,
5216
- reason: WAITING_STOPPED
5217
- });
5218
- continue;
5710
+ try {
5711
+ return await this.exclusive(owner, async () => {
5712
+ await this.up(owner === PERSON_OWNER);
5713
+ const views = [];
5714
+ for (const item of items) {
5715
+ if (signal?.aborted) {
5716
+ views.push({
5717
+ url: item.url,
5718
+ reason: WAITING_STOPPED
5719
+ });
5720
+ continue;
5721
+ }
5722
+ views.push(await this.openAddress(owner, item, signal, reveal));
5219
5723
  }
5220
- views.push(await this.openAddress(owner, item, signal, reveal));
5221
- }
5222
- await this.enforceCap(owner);
5223
- return views;
5224
- });
5724
+ await this.enforceCap(owner);
5725
+ return views;
5726
+ }, signal);
5727
+ } catch (error) {
5728
+ if (!isInterrupted(error)) throw error;
5729
+ return items.map((item) => ({
5730
+ url: item.url,
5731
+ reason: WAITING_STOPPED
5732
+ }));
5733
+ }
5225
5734
  }
5226
5735
  async view(owner, tab, signal) {
5227
5736
  return this.exclusive(owner, async () => {
@@ -5233,8 +5742,8 @@ let MhBrowserService = (() => {
5233
5742
  url: live.url,
5234
5743
  reason: WAITING_STOPPED
5235
5744
  };
5236
- return this.refreshView(live);
5237
- });
5745
+ return this.refreshView(live, signal);
5746
+ }, signal);
5238
5747
  }
5239
5748
  /** Pages whose current representation is already in the model's presence. */
5240
5749
  rememberPresence(owner, urls) {
@@ -5300,7 +5809,7 @@ let MhBrowserService = (() => {
5300
5809
  text: planned.text,
5301
5810
  record: planned.record
5302
5811
  };
5303
- });
5812
+ }, opts?.signal);
5304
5813
  }
5305
5814
  /**
5306
5815
  * Where a download the page starts may be written: the session's writable
@@ -5342,7 +5851,7 @@ let MhBrowserService = (() => {
5342
5851
  kind: "response",
5343
5852
  finalUrl: response.url(),
5344
5853
  status: response.status(),
5345
- body: new Uint8Array(await response.body())
5854
+ body: new Uint8Array(await untilAborted(response.body(), capped(opts?.signal, READ_MS)))
5346
5855
  };
5347
5856
  const mediaType = headerMap["content-type"];
5348
5857
  const etag = headerMap.etag;
@@ -5382,9 +5891,26 @@ let MhBrowserService = (() => {
5382
5891
  page: (owner, tab) => this.windows.get(owner, tab)?.page,
5383
5892
  running: () => this.context !== void 0,
5384
5893
  pages: () => this.context?.pages() ?? [],
5385
- pid: () => this.context === void 0 ? void 0 : this.pids.get(this.context)
5894
+ pid: () => this.context === void 0 ? void 0 : this.pids.get(this.context),
5895
+ useLocator: (env) => {
5896
+ this.locator = env;
5897
+ }
5386
5898
  };
5387
5899
  }
5900
+ /**
5901
+ * Where `browser` would be launched from when no path is configured.
5902
+ * The configured path is not consulted: the answer is what an empty path uses.
5903
+ */
5904
+ async locateFor(browser, signal) {
5905
+ const stopped = () => signal?.aborted === true;
5906
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
5907
+ if (browser !== "chrome" && browser !== "msedge" && browser !== "chromium") throw new MhBrowserError("INVALID_ARGS", "browser must be chrome, msedge, or chromium");
5908
+ const env = this.locateEnv();
5909
+ if (browser === "chromium") return bundledChromiumLocation(env.access);
5910
+ const located = await locateInstalled(browser, env, signal);
5911
+ if (stopped()) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
5912
+ return located;
5913
+ }
5388
5914
  async act(owner, tab, op, signal) {
5389
5915
  return this.exclusive(owner, async () => {
5390
5916
  await this.ready;
@@ -5396,7 +5922,7 @@ let MhBrowserService = (() => {
5396
5922
  try {
5397
5923
  if (live.dialogHeld && op.kind !== "handle_dialog") throw new MhBrowserError("MODAL_STATE", "a dialog is open; use browser_handle_dialog", await this.blockedView(live));
5398
5924
  if (live.fileChooser !== void 0 && op.kind !== "file_upload") {
5399
- const blocked = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" });
5925
+ const blocked = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal);
5400
5926
  blocked.dialog = {
5401
5927
  type: "filechooser",
5402
5928
  message: live.fileChooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
@@ -5409,8 +5935,8 @@ let MhBrowserService = (() => {
5409
5935
  else this.windows.holdInput(live);
5410
5936
  } else if (outcome.navigated) this.windows.onNavigated(live);
5411
5937
  this.windows.touch(live);
5412
- const shown = outcome.navigated ? await this.discover(live, live.page.url(), null) : await this.refreshSnapshot(live);
5413
- const scoped = op.kind === "snapshot" && (op.target !== void 0 || op.depth !== void 0 || op.boxes === true) ? await this.scopedSnapshot(live, op) : void 0;
5938
+ const shown = outcome.navigated ? await this.discover(live, live.page.url(), null, signal) : await this.refreshSnapshot(live, signal);
5939
+ const scoped = op.kind === "snapshot" && (op.target !== void 0 || op.depth !== void 0 || op.boxes === true) ? await this.scopedSnapshot(live, op, signal) : void 0;
5414
5940
  this.shown.set(live.id, shown);
5415
5941
  this.windows.noteUrl(live, live.page.url());
5416
5942
  const view = await this.pageFromShown(live, shown, {
@@ -5418,7 +5944,7 @@ let MhBrowserService = (() => {
5418
5944
  ...outcome.dialog !== void 0 ? { dialog: outcome.dialog } : {},
5419
5945
  ...outcome.incomplete !== void 0 ? { incomplete: outcome.incomplete } : {},
5420
5946
  ...outcome.waited !== void 0 ? { waited: outcome.waited } : {}
5421
- });
5947
+ }, signal);
5422
5948
  if (scoped !== void 0 && view.representation.kind === "snapshot") view.representation = {
5423
5949
  ...view.representation,
5424
5950
  text: scoped
@@ -5435,12 +5961,12 @@ let MhBrowserService = (() => {
5435
5961
  } catch (error) {
5436
5962
  if (error instanceof MhBrowserError && error.code === "TAKEN_OVER") throw error;
5437
5963
  if (this.control.ofTab(owner, tab) === "person") throw await this.takenOver(live);
5438
- if (error instanceof MhBrowserError && error.code === "STALE_REF" && error.page === void 0) throw new MhBrowserError(error.code, error.message, await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }));
5964
+ if (error instanceof MhBrowserError && error.code === "STALE_REF" && error.page === void 0) throw new MhBrowserError(error.code, error.message, await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }, signal));
5439
5965
  throw error;
5440
5966
  } finally {
5441
5967
  linked.release();
5442
5968
  }
5443
- });
5969
+ }, signal);
5444
5970
  }
5445
5971
  holdHandover(owner, tab) {
5446
5972
  const live = this.windows.get(owner, tab);
@@ -5458,15 +5984,16 @@ let MhBrowserService = (() => {
5458
5984
  const live = this.windows.get(owner, tab);
5459
5985
  if (live === void 0 || live.dialogHeld) return void 0;
5460
5986
  try {
5461
- const bytes = await live.page.screenshot({
5987
+ const limit = capped(signal, READ_MS);
5988
+ const bytes = await untilAborted(live.page.screenshot({
5462
5989
  type: "png",
5463
- ...signal !== void 0 ? { signal } : {}
5464
- });
5990
+ signal: limit
5991
+ }), limit);
5465
5992
  return new Uint8Array(bytes);
5466
5993
  } catch {
5467
5994
  return;
5468
5995
  }
5469
- });
5996
+ }, signal);
5470
5997
  }
5471
5998
  invoke(owner, _page, call, opts) {
5472
5999
  return this.exclusive(owner, async () => {
@@ -5483,7 +6010,7 @@ let MhBrowserService = (() => {
5483
6010
  }, call);
5484
6011
  await this.enforceCap(owner);
5485
6012
  return sent;
5486
- });
6013
+ }, opts?.signal);
5487
6014
  }
5488
6015
  /**
5489
6016
  * Not queued: it opens no tab, and a long body must not hold up the
@@ -5515,11 +6042,11 @@ let MhBrowserService = (() => {
5515
6042
  const live = this.windows.get(owner, req.tab);
5516
6043
  if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
5517
6044
  this.windows.holdHandover(live);
5518
- await this.windows.activate(live).catch(() => void 0);
6045
+ await this.windows.activate(live, signal).catch(() => void 0);
5519
6046
  }
5520
6047
  this.control.handover(owner, req.tab);
5521
6048
  this.windows.markWaiting(owner, true);
5522
- });
6049
+ }, signal);
5523
6050
  }
5524
6051
  /** Remember the Session title for its tab group. Empty when the host has none. */
5525
6052
  noteSessionTitle(owner, title) {
@@ -5530,7 +6057,7 @@ let MhBrowserService = (() => {
5530
6057
  await this.ready;
5531
6058
  const live = this.windows.latest(owner);
5532
6059
  if (live === void 0) return false;
5533
- await live.page.bringToFront();
6060
+ await untilAborted(live.page.bringToFront(), AbortSignal.timeout(READ_MS));
5534
6061
  return true;
5535
6062
  }
5536
6063
  /** The person asked to see the page this Session handed over. */
@@ -5541,11 +6068,12 @@ let MhBrowserService = (() => {
5541
6068
  const named = this.control.namedTab(sessionId);
5542
6069
  const live = (named === void 0 ? void 0 : this.windows.get(sessionId, named)) ?? this.windows.latest(sessionId);
5543
6070
  if (live === void 0) return { status: "no-tab" };
5544
- await live.page.bringToFront();
6071
+ await untilAborted(live.page.bringToFront(), capped(signal, READ_MS));
5545
6072
  return { status: "revealed" };
5546
6073
  }
5547
6074
  /** The person answered. Returns the note, the operation count, and the named tab. */
5548
6075
  async handBack(owner, note) {
6076
+ const signal = capped(void 0, READ_MS);
5549
6077
  return this.exclusive(owner, async () => {
5550
6078
  await this.ready;
5551
6079
  const taken = this.control.finish(owner);
@@ -5554,32 +6082,36 @@ let MhBrowserService = (() => {
5554
6082
  this.windows.markWaiting(owner, false);
5555
6083
  const urls = this.presenceUrls(owner, taken.tabs);
5556
6084
  for (const touch of this.personTouch) touch(owner, urls);
5557
- const page = await this.snapshotNamed(owner, taken.tab);
6085
+ const page = await this.snapshotNamed(owner, taken.tab, signal);
5558
6086
  return {
5559
6087
  ...taken,
5560
6088
  urls,
5561
6089
  ...note !== void 0 && note !== "" ? { note } : {},
5562
6090
  ...page !== void 0 ? { page } : {}
5563
6091
  };
5564
- });
6092
+ }, signal);
5565
6093
  }
5566
6094
  /** A stopped handover gives control back. A finished hand-back is left alone. */
5567
6095
  releaseIfHeld(owner) {
5568
6096
  if (!this.control.releaseIfHeld(owner)) return;
5569
6097
  this.windows.releaseHandover(owner);
5570
6098
  this.windows.markWaiting(owner, false);
6099
+ this.relaunchIfIdle();
5571
6100
  }
5572
6101
  /**
5573
6102
  * The person took over between turns. The next message they send is the
5574
6103
  * hand-back. A handover that is still waiting on its card is not.
6104
+ * The addresses are read before a waiting restart closes the pages.
5575
6105
  */
5576
6106
  takePersonHandback(owner) {
5577
6107
  const taken = this.control.takePerson(owner);
5578
6108
  if (taken === void 0) return void 0;
6109
+ const urls = this.presenceUrls(owner, taken.tabs);
5579
6110
  this.windows.releaseHandover(owner);
6111
+ this.relaunchIfIdle();
5580
6112
  return {
5581
6113
  ...taken,
5582
- urls: this.presenceUrls(owner, taken.tabs)
6114
+ urls
5583
6115
  };
5584
6116
  }
5585
6117
  /**
@@ -5603,7 +6135,7 @@ let MhBrowserService = (() => {
5603
6135
  }
5604
6136
  const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true, reveal);
5605
6137
  this.keepAgentView(opened);
5606
- if (!wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
6138
+ if (opened.live !== void 0 && !wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
5607
6139
  await this.windows.closeTab(opened.live);
5608
6140
  const view = { ...opened.view };
5609
6141
  delete view.tab;
@@ -5629,6 +6161,7 @@ let MhBrowserService = (() => {
5629
6161
  }
5630
6162
  /** A readable Agent View puts its origin on the list and keeps the document for the next read. */
5631
6163
  keepAgentView(opened) {
6164
+ if (opened.live === void 0) return;
5632
6165
  if (!("representation" in opened.view)) return;
5633
6166
  const representation = opened.view.representation;
5634
6167
  if (!(representation.kind === "agent-view" || representation.kind === "snapshot" && representation.agentView !== void 0)) return;
@@ -5760,7 +6293,7 @@ let MhBrowserService = (() => {
5760
6293
  kind: "response",
5761
6294
  finalUrl: current,
5762
6295
  status,
5763
- body: readsAsText(mediaType) ? new Uint8Array(await response.body()) : /* @__PURE__ */ new Uint8Array()
6296
+ body: readsAsText(mediaType) ? new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))) : /* @__PURE__ */ new Uint8Array()
5764
6297
  };
5765
6298
  const etag = headerMap.etag;
5766
6299
  const link = headerMap.link;
@@ -5773,8 +6306,20 @@ let MhBrowserService = (() => {
5773
6306
  async openOne(owner, url, waitFor, signal, snapshot = false, reveal = false) {
5774
6307
  assertWaitFor(waitFor);
5775
6308
  const reused = this.idleTabOn(owner, url);
5776
- const live = reused ?? await this.windows.openBlank(owner);
5777
6309
  const fresh = reused === void 0;
6310
+ let live;
6311
+ try {
6312
+ live = reused ?? await this.windows.openBlank(owner, signal);
6313
+ } catch (error) {
6314
+ if (signal?.aborted === true || isInterrupted(error)) return {
6315
+ view: {
6316
+ url,
6317
+ reason: WAITING_STOPPED
6318
+ },
6319
+ fresh
6320
+ };
6321
+ throw error;
6322
+ }
5778
6323
  const linked = this.control.link(owner, signal, live.id);
5779
6324
  if (reveal && !linked.signal.aborted) await live.page.bringToFront().catch(() => void 0);
5780
6325
  const nextDialog = this.windows.nextDialog(live);
@@ -5797,7 +6342,7 @@ let MhBrowserService = (() => {
5797
6342
  nextDialog.cancel();
5798
6343
  if (winner === "failed" && seenDialog === void 0) {
5799
6344
  if (isDownloadStart(navigationError) || live.pendingDownload !== void 0) {
5800
- const view = await this.pageFromShown(live, this.shown.get(live.id));
6345
+ const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
5801
6346
  if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
5802
6347
  this.applyDocumentType(live, view);
5803
6348
  return {
@@ -5848,8 +6393,8 @@ let MhBrowserService = (() => {
5848
6393
  ...optionalFragment(address),
5849
6394
  snapshot: "",
5850
6395
  relocate: false,
5851
- fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch)
5852
- }) : await this.discover(live, address, response);
6396
+ fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6397
+ }) : await this.discover(live, address, response, signal);
5853
6398
  this.shown.set(live.id, shown);
5854
6399
  const view = await this.pageFromShown(live, shown, {
5855
6400
  ...snapshot ? { surface: "snapshot" } : {},
@@ -5859,7 +6404,7 @@ let MhBrowserService = (() => {
5859
6404
  } },
5860
6405
  ...load.dialog !== void 0 ? { dialog: load.dialog } : {},
5861
6406
  ...waited !== void 0 ? { waited } : {}
5862
- });
6407
+ }, signal);
5863
6408
  return {
5864
6409
  view: await this.attachDownload(live, view, false),
5865
6410
  fresh,
@@ -5894,7 +6439,7 @@ let MhBrowserService = (() => {
5894
6439
  if (op.kind === "snapshot") return { navigated: false };
5895
6440
  if (op.kind === "find") return {
5896
6441
  navigated: false,
5897
- summary: findInSnapshot(await this.aria(live), op)
6442
+ summary: findInSnapshot(await this.aria(live, signal), op)
5898
6443
  };
5899
6444
  if (op.kind === "evaluate") {
5900
6445
  const snapshot = this.shown.get(live.id)?.snapshot ?? "";
@@ -6086,15 +6631,16 @@ let MhBrowserService = (() => {
6086
6631
  };
6087
6632
  }
6088
6633
  /** A snapshot limited to a target, a depth, or boxes. Refs still resolve against the full one. */
6089
- async scopedSnapshot(tab, op) {
6634
+ async scopedSnapshot(tab, op, signal) {
6090
6635
  const options = {
6091
6636
  mode: "ai",
6092
6637
  ...op.depth !== void 0 ? { depth: op.depth } : {},
6093
6638
  ...op.boxes === true ? { boxes: true } : {}
6094
6639
  };
6095
6640
  const snapshot = this.shown.get(tab.id)?.snapshot ?? "";
6096
- if (op.target !== void 0) return (await locate(tab.page, op.target, snapshot)).ariaSnapshot(options);
6097
- return tab.page.ariaSnapshot(options);
6641
+ const limit = capped(signal, READ_MS);
6642
+ if (op.target !== void 0) return untilAborted((await locate(tab.page, op.target, snapshot)).ariaSnapshot(options), limit);
6643
+ return untilAborted(tab.page.ariaSnapshot(options), limit);
6098
6644
  }
6099
6645
  /** A file chooser the action opened. The tab stays in use until it navigates. */
6100
6646
  chooserHeld(live, chooser) {
@@ -6105,9 +6651,9 @@ let MhBrowserService = (() => {
6105
6651
  message: chooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
6106
6652
  };
6107
6653
  }
6108
- async refreshSnapshot(tab) {
6654
+ async refreshSnapshot(tab, signal) {
6109
6655
  const previous = this.shown.get(tab.id);
6110
- const snapshot = tab.dialogHeld ? "" : await this.aria(tab);
6656
+ const snapshot = tab.dialogHeld ? "" : await this.aria(tab, signal);
6111
6657
  if (previous === void 0) return {
6112
6658
  pageUrl: canonical(tab.page.url()),
6113
6659
  digest: "",
@@ -6142,20 +6688,20 @@ let MhBrowserService = (() => {
6142
6688
  this.windows.endDispatch(page);
6143
6689
  }
6144
6690
  }
6145
- async discover(tab, address, response) {
6146
- const main = response === null ? void 0 : await mainDocument(response);
6691
+ async discover(tab, address, response, signal) {
6692
+ const main = response === null ? void 0 : await mainDocument(response, signal);
6147
6693
  const relocated = main !== void 0 && !samePage(main.finalUrl, address);
6148
- const domLinks = relocated ? [] : await readDomLinks(tab.page);
6694
+ const domLinks = relocated ? [] : await readDomLinks(tab.page, signal);
6149
6695
  const previous = this.shown.get(tab.id);
6150
6696
  return loadShown({
6151
6697
  pageUrl: canonical(address),
6152
6698
  ...optionalFragment(address),
6153
- snapshot: await this.aria(tab),
6699
+ snapshot: await this.aria(tab, signal),
6154
6700
  ...main !== void 0 && !relocated ? { main } : {},
6155
6701
  ...domLinks.length > 0 ? { domLinks } : {},
6156
6702
  ...previous !== void 0 ? { previous } : {},
6157
6703
  relocate: relocated,
6158
- fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch)
6704
+ fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
6159
6705
  });
6160
6706
  }
6161
6707
  fetchForRead(url, accept, ifNoneMatch, signal) {
@@ -6165,13 +6711,13 @@ let MhBrowserService = (() => {
6165
6711
  ...signal !== void 0 ? { signal } : {}
6166
6712
  });
6167
6713
  }
6168
- async aria(tab) {
6169
- return tab.page.ariaSnapshot({ mode: "ai" }).catch((error) => {
6714
+ async aria(tab, signal) {
6715
+ return untilAborted(tab.page.ariaSnapshot({ mode: "ai" }).catch((error) => {
6170
6716
  this.ctx.logger.debug("aria snapshot failed: %s", errorText(error));
6171
6717
  return "";
6172
- });
6718
+ }), capped(signal, READ_MS));
6173
6719
  }
6174
- async pageFromShown(tab, shown, extra) {
6720
+ async pageFromShown(tab, shown, extra, signal) {
6175
6721
  const current = shown ?? {
6176
6722
  pageUrl: canonical(tab.url),
6177
6723
  digest: "",
@@ -6179,10 +6725,10 @@ let MhBrowserService = (() => {
6179
6725
  text: "",
6180
6726
  snapshot: ""
6181
6727
  };
6182
- const title = tab.dialogHeld ? "" : await tab.page.title().catch((error) => {
6728
+ const title = tab.dialogHeld ? "" : await untilAborted(tab.page.title().catch((error) => {
6183
6729
  this.ctx.logger.debug("page title failed: %s", errorText(error));
6184
6730
  return "";
6185
- });
6731
+ }), capped(signal, READ_MS));
6186
6732
  this.windows.noteTitle(tab, title);
6187
6733
  const view = {
6188
6734
  tab: tab.id,
@@ -6227,7 +6773,7 @@ let MhBrowserService = (() => {
6227
6773
  if (via !== void 0) return observationOf(url, via);
6228
6774
  }
6229
6775
  const existing = this.tabOn(owner, url);
6230
- if (existing !== void 0) return observationOf(url, await this.refreshView(existing));
6776
+ if (existing !== void 0) return observationOf(url, await this.refreshView(existing, signal));
6231
6777
  const opened = await this.openOne(owner, url, void 0, signal, false);
6232
6778
  this.keepAgentView(opened);
6233
6779
  const view = opened.view;
@@ -6251,22 +6797,22 @@ let MhBrowserService = (() => {
6251
6797
  tabs.sort((left, right) => right.touchedAt - left.touchedAt);
6252
6798
  return tabs[0];
6253
6799
  }
6254
- async refreshView(live) {
6800
+ async refreshView(live, signal) {
6255
6801
  this.windows.touch(live);
6256
- if (live.dialogHeld) return this.pageFromShown(live, this.shown.get(live.id));
6802
+ if (live.dialogHeld) return this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6257
6803
  const address = live.page.url();
6258
6804
  const previous = this.shown.get(live.id);
6259
6805
  const shown = await loadShown({
6260
6806
  pageUrl: canonical(address),
6261
6807
  ...optionalFragment(address),
6262
- snapshot: await this.aria(live),
6808
+ snapshot: await this.aria(live, signal),
6263
6809
  ...previous !== void 0 ? { previous } : {},
6264
6810
  relocate: previous !== void 0 && !samePage(previous.pageUrl, address),
6265
- fetch: (url, accept, ifNoneMatch) => this.fetchForRead(url, accept, ifNoneMatch)
6811
+ fetch: (url, accept, ifNoneMatch) => this.fetchForRead(url, accept, ifNoneMatch, signal)
6266
6812
  });
6267
6813
  this.shown.set(live.id, shown);
6268
6814
  this.windows.noteUrl(live, address);
6269
- return this.pageFromShown(live, shown);
6815
+ return this.pageFromShown(live, shown, void 0, signal);
6270
6816
  }
6271
6817
  /** The most recently touched idle tab on this address. A tab the person holds is left alone. */
6272
6818
  idleTabOn(owner, url) {
@@ -6299,7 +6845,7 @@ let MhBrowserService = (() => {
6299
6845
  const load = await waitForLoad(live.page, signal);
6300
6846
  if (load.dialog !== void 0) this.windows.holdDialog(live);
6301
6847
  this.windows.touch(live);
6302
- const shown = await this.discover(live, live.page.url(), response);
6848
+ const shown = await this.discover(live, live.page.url(), response, signal);
6303
6849
  this.shown.set(live.id, shown);
6304
6850
  const view = await this.pageFromShown(live, shown, {
6305
6851
  ...load.load ? {} : { incomplete: {
@@ -6307,11 +6853,11 @@ let MhBrowserService = (() => {
6307
6853
  pendingRequests: load.pendingRequests
6308
6854
  } },
6309
6855
  ...load.dialog !== void 0 ? { dialog: load.dialog } : {}
6310
- });
6856
+ }, signal);
6311
6857
  return this.attachDownload(live, view, false);
6312
6858
  } catch (error) {
6313
6859
  if (isDownloadStart(error) || live.pendingDownload !== void 0) {
6314
- const view = await this.pageFromShown(live, this.shown.get(live.id));
6860
+ const view = await this.pageFromShown(live, this.shown.get(live.id), void 0, signal);
6315
6861
  if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
6316
6862
  this.applyDocumentType(live, view);
6317
6863
  return this.attachDownload(live, view, true);
@@ -6343,7 +6889,7 @@ let MhBrowserService = (() => {
6343
6889
  return {
6344
6890
  kind: "response",
6345
6891
  status: response.status(),
6346
- body: new Uint8Array(await response.body()),
6892
+ body: new Uint8Array(await untilAborted(response.body(), capped(signal, READ_MS))),
6347
6893
  ...location === void 0 || location === "" ? {} : { location },
6348
6894
  ...mediaType === void 0 ? {} : { mediaType }
6349
6895
  };
@@ -6449,8 +6995,10 @@ let MhBrowserService = (() => {
6449
6995
  return started;
6450
6996
  }
6451
6997
  async start() {
6452
- const context = await launchBrowser(this.launchOptions);
6453
- const pid = await browserPid(context, this.launchOptions.profileDir);
6998
+ const choice = this.launchChoice();
6999
+ const epoch = this.configEpoch;
7000
+ const context = await launchBrowser(choice, this.locateEnv());
7001
+ const pid = await browserPid(context, choice.profileDir);
6454
7002
  if (pid !== void 0) this.pids.set(context, pid);
6455
7003
  if (this.closing) {
6456
7004
  await this.closeContext(context);
@@ -6458,15 +7006,16 @@ let MhBrowserService = (() => {
6458
7006
  }
6459
7007
  this.context = context;
6460
7008
  try {
6461
- await this.windows.attach(context, this.launchOptions.profileDir, { headed: !this.launchOptions.headless });
7009
+ await this.windows.attach(context, choice.profileDir, { headed: !choice.headless });
6462
7010
  } catch (error) {
6463
7011
  this.context = void 0;
6464
7012
  this.windows.dropPages();
6465
7013
  await this.closeContext(context);
6466
7014
  throw error;
6467
7015
  }
7016
+ if (this.configEpoch === epoch) this.relaunchWanted = false;
6468
7017
  context.browser()?.on("disconnected", () => {
6469
- if (this.closing || this.context !== context) return;
7018
+ if (this.closing || this.replacing || this.context !== context) return;
6470
7019
  this.onExit();
6471
7020
  });
6472
7021
  }
@@ -6537,22 +7086,81 @@ let MhBrowserService = (() => {
6537
7086
  }
6538
7087
  return urls;
6539
7088
  }
6540
- async snapshotNamed(owner, tab) {
7089
+ async snapshotNamed(owner, tab, signal) {
6541
7090
  if (tab === void 0) return void 0;
6542
7091
  const live = this.windows.get(owner, tab);
6543
7092
  if (live === void 0) return void 0;
6544
- const shown = await this.refreshSnapshot(live);
7093
+ const shown = await this.refreshSnapshot(live, signal);
6545
7094
  this.shown.set(live.id, shown);
6546
- return this.pageFromShown(live, shown, { surface: "snapshot" });
6547
- }
6548
- exclusive(owner, op) {
6549
- const run = (this.queues.get(owner) ?? Promise.resolve()).then(op, op);
6550
- const tail = run.then(() => void 0, () => void 0);
6551
- this.queues.set(owner, tail);
6552
- tail.then(() => {
6553
- if (this.queues.get(owner) === tail) this.queues.delete(owner);
7095
+ return this.pageFromShown(live, shown, { surface: "snapshot" }, signal);
7096
+ }
7097
+ exclusive(owner, op, signal) {
7098
+ const queued = enqueue(this.queues.get(owner) ?? Promise.resolve(), op, signal);
7099
+ this.queues.set(owner, queued.tail);
7100
+ queued.tail.then(() => {
7101
+ if (this.queues.get(owner) === queued.tail) this.queues.delete(owner);
7102
+ if (this.queues.size === 0) this.relaunchIfIdle();
6554
7103
  });
6555
- return run;
7104
+ return queued.run;
7105
+ }
7106
+ launchChoice() {
7107
+ const configured = this.executablePath.get();
7108
+ return {
7109
+ browser: this.browserName.get(),
7110
+ headless: this.headless,
7111
+ profileDir: this.profileDir,
7112
+ ...configured !== void 0 && configured !== "" ? { executablePath: configured } : {}
7113
+ };
7114
+ }
7115
+ locateEnv() {
7116
+ return this.locator ?? hostLocateEnv();
7117
+ }
7118
+ idle() {
7119
+ return this.queues.size === 0 && !this.control.busy();
7120
+ }
7121
+ /**
7122
+ * Close and start again when a configuration change is waiting and nothing
7123
+ * is using the browser. A browser that is not running keeps the new values
7124
+ * for the next start. The close is not counted as a crash.
7125
+ */
7126
+ relaunchIfIdle() {
7127
+ if (!this.relaunchWanted || this.closing || this.replacing) return;
7128
+ if (!this.idle()) return;
7129
+ if (this.context === void 0) {
7130
+ if (this.exited) {
7131
+ this.relaunchWanted = false;
7132
+ return;
7133
+ }
7134
+ this.ready.then(() => this.relaunchIfIdle(), () => {
7135
+ this.relaunchWanted = false;
7136
+ });
7137
+ return;
7138
+ }
7139
+ this.relaunchWanted = false;
7140
+ this.replacing = true;
7141
+ const run = this.replaceBrowser();
7142
+ this.ready = run;
7143
+ const finish = () => {
7144
+ this.replacing = false;
7145
+ if (this.relaunchWanted) this.relaunchIfIdle();
7146
+ };
7147
+ run.then(finish, finish);
7148
+ }
7149
+ async replaceBrowser() {
7150
+ this.windows.suppressPersonInput();
7151
+ const context = this.context;
7152
+ this.context = void 0;
7153
+ this.windows.dropPages();
7154
+ if (context !== void 0) await this.closeContext(context);
7155
+ if (this.closing) return;
7156
+ this.exited = false;
7157
+ try {
7158
+ await this.launch();
7159
+ this.windows.resumePersonInput();
7160
+ } catch (error) {
7161
+ this.exited = true;
7162
+ throw error;
7163
+ }
6556
7164
  }
6557
7165
  };
6558
7166
  })();
@@ -6600,24 +7208,24 @@ function fragmentOf(url) {
6600
7208
  return;
6601
7209
  }
6602
7210
  }
6603
- async function mainDocument(response) {
7211
+ async function mainDocument(response, signal) {
6604
7212
  const headers = response.headers();
6605
7213
  const mediaType = headers["content-type"];
6606
7214
  const link = headers.link;
6607
7215
  return {
6608
7216
  status: response.status(),
6609
7217
  finalUrl: response.url(),
6610
- body: await response.text(),
7218
+ body: await untilAborted(response.text(), capped(signal, READ_MS)),
6611
7219
  ...mediaType !== void 0 ? { mediaType } : {},
6612
7220
  ...link !== void 0 ? { link } : {}
6613
7221
  };
6614
7222
  }
6615
- async function readDomLinks(page) {
6616
- return (await page.locator("link").evaluateAll((elements) => elements.map((element) => ({
7223
+ async function readDomLinks(page, signal) {
7224
+ return (await untilAborted(page.locator("link").evaluateAll((elements) => elements.map((element) => ({
6617
7225
  rel: element.getAttribute("rel") ?? "",
6618
7226
  href: element.getAttribute("href") ?? "",
6619
7227
  title: element.getAttribute("title") ?? ""
6620
- }))).catch(() => [])).filter((row) => row.rel !== "" && row.href !== "").map((row) => ({
7228
+ }))).catch(() => []), capped(signal, READ_MS))).filter((row) => row.rel !== "" && row.href !== "").map((row) => ({
6621
7229
  rel: row.rel,
6622
7230
  href: row.href,
6623
7231
  ...row.title !== "" ? { title: row.title } : {}
@@ -6890,9 +7498,10 @@ function browserHandoverTool(ctx, reader, headed = true) {
6890
7498
  if (exec.signal.aborted) return quiet();
6891
7499
  const session = sessionFor(ctx, exec);
6892
7500
  try {
7501
+ const tab = givenString(args.tab);
6893
7502
  await ctx.mhBrowser.handover(session.id, {
6894
7503
  reason: args.reason,
6895
- ...args.tab === void 0 ? {} : { tab: args.tab }
7504
+ ...tab === void 0 ? {} : { tab }
6896
7505
  }, exec.signal);
6897
7506
  const answer = await askUntilAnswered(ctx, args.reason, exec, headed);
6898
7507
  if (answer === ABORT || exec.signal.aborted) return quiet();
@@ -7036,6 +7645,7 @@ function browserInvokeTool(ctx, reader) {
7036
7645
  if (resolved === void 0) throw new BrowserToolError("NOT_DELIVERED", `${NOT_DELIVERED_REASON}: ${args.page}`);
7037
7646
  if ("ambiguous" in resolved) throw new BrowserToolError("INVALID_ARGS", `${args.page} is the id of more than one page shown to you; give the url of one: ${resolved.ambiguous.join(", ")}`);
7038
7647
  const input = args.input === void 0 ? void 0 : asObject(args.input);
7648
+ const item = givenString(args.item);
7039
7649
  try {
7040
7650
  const sent = await ctx.mhBrowser.invoke(session.id, resolved.url, {
7041
7651
  name: args.name,
@@ -7043,7 +7653,7 @@ function browserInvokeTool(ctx, reader) {
7043
7653
  url: resolved.url,
7044
7654
  text: resolved.text
7045
7655
  },
7046
- ...args.item === void 0 ? {} : { item: args.item },
7656
+ ...item === void 0 ? {} : { item },
7047
7657
  ...input === void 0 ? {} : { input }
7048
7658
  }, {
7049
7659
  signal: exec.signal,
@@ -7168,18 +7778,19 @@ function browserOpenTool(ctx, reader) {
7168
7778
  }),
7169
7779
  async execute(args, exec) {
7170
7780
  if (args.pages.length < 1 || args.pages.length > 8) throw new BrowserToolError("INVALID_ARGS", "pages takes 1 to 8 entries");
7171
- for (const page of args.pages) {
7172
- const waitFor = page.waitFor;
7781
+ const items = args.pages.map((page) => {
7782
+ const waitFor = page.waitFor === void 0 ? void 0 : compactWait(page.waitFor);
7173
7783
  if (waitFor !== void 0 && waitFor.text === void 0 && waitFor.textGone === void 0 && waitFor.time === void 0) throw new BrowserToolError("INVALID_ARGS", "waitFor needs text, textGone, or time");
7174
- }
7784
+ return {
7785
+ url: page.url,
7786
+ ...page.snapshot === true ? { snapshot: true } : {},
7787
+ ...waitFor === void 0 ? {} : { waitFor }
7788
+ };
7789
+ });
7175
7790
  const session = sessionFor(ctx, exec);
7176
7791
  ctx.mhBrowser.noteWorkspace(session.id, writeTarget(ctx, session));
7177
7792
  try {
7178
- const pages = await ctx.mhBrowser.open(session.id, args.pages.map((page) => ({
7179
- url: page.url,
7180
- ...page.snapshot === true ? { snapshot: true } : {},
7181
- ...page.waitFor === void 0 ? {} : { waitFor: compactWait(page.waitFor) }
7182
- })), exec.signal);
7793
+ const pages = await ctx.mhBrowser.open(session.id, items, exec.signal);
7183
7794
  const block = tabBlock(ctx, reader, session);
7184
7795
  return {
7185
7796
  text: `${renderOutcome(pages, void 0, true)}${block.suffix}`,
@@ -7196,10 +7807,13 @@ function browserOpenTool(ctx, reader) {
7196
7807
  });
7197
7808
  }
7198
7809
  function compactWait(waitFor) {
7810
+ const text = givenString(waitFor.text);
7811
+ const textGone = givenString(waitFor.textGone);
7812
+ const time = givenTime(waitFor.time);
7199
7813
  return {
7200
- ...waitFor.text !== void 0 ? { text: waitFor.text } : {},
7201
- ...waitFor.textGone !== void 0 ? { textGone: waitFor.textGone } : {},
7202
- ...waitFor.time !== void 0 ? { time: waitFor.time } : {}
7814
+ ...text === void 0 ? {} : { text },
7815
+ ...textGone === void 0 ? {} : { textGone },
7816
+ ...time === void 0 ? {} : { time }
7203
7817
  };
7204
7818
  }
7205
7819
  function titleOf(name, url) {
@@ -7313,6 +7927,7 @@ const inject = [
7313
7927
  ];
7314
7928
  const Config = browserConfig;
7315
7929
  async function apply(ctx, config) {
7930
+ const resolved = browserSettings(config);
7316
7931
  ctx.plugin({
7317
7932
  name: "mh-browser-slot",
7318
7933
  inject: ["browserUse", "mhBrowser"],
@@ -7320,16 +7935,29 @@ async function apply(ctx, config) {
7320
7935
  inner.browserUse.register("mh-browser");
7321
7936
  }
7322
7937
  });
7323
- await ctx.plugin(MhBrowserService, config);
7938
+ await ctx.plugin(MhBrowserService, resolved);
7324
7939
  await ctx.plugin({
7325
7940
  name: "mh-browser-runtime",
7326
7941
  inject: ["mhBrowser", ...inject],
7327
7942
  async apply(inner) {
7328
7943
  const reader = await registerProjections(inner);
7329
- registerBrowserTools(inner, reader, !config.headless);
7944
+ registerBrowserTools(inner, reader, !resolved.headless);
7330
7945
  registerPresence(inner, reader);
7331
7946
  }
7332
7947
  });
7333
7948
  }
7949
+ /**
7950
+ * The loader passes a config it has already resolved, including the volatile
7951
+ * fields. A direct caller passes the raw values, which are resolved here.
7952
+ * Resolving twice rejects a volatile value as if it were the wrong type.
7953
+ */
7954
+ function browserSettings(config) {
7955
+ if (isResolved(config)) return config;
7956
+ return browserConfig(config);
7957
+ }
7958
+ function isResolved(config) {
7959
+ const browser = config.browser;
7960
+ return typeof browser === "object" && browser !== null && "get" in browser;
7961
+ }
7334
7962
  //#endregion
7335
7963
  export { Config, MhBrowserError, MhBrowserService, UNKNOWN_TAB, UNKNOWN_TAB_REASON, apply, inject, name, readAgentView, unknownTab };