@orkestrel/browser 0.0.21 → 0.0.23

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.
@@ -13,6 +13,7 @@ let _orkestrel_emitter = require("@orkestrel/emitter");
13
13
  let _orkestrel_websocket = require("@orkestrel/websocket");
14
14
  let node_events = require("node:events");
15
15
  let _orkestrel_mcp = require("@orkestrel/mcp");
16
+ let _orkestrel_pool = require("@orkestrel/pool");
16
17
  let _orkestrel_mcp_server = require("@orkestrel/mcp/server");
17
18
  let _orkestrel_tool = require("@orkestrel/tool");
18
19
  //#region src/server/constants.ts
@@ -212,6 +213,30 @@ var BROWSER_RUN_FILE = "run.json";
212
213
  var BROWSER_RUN_DIRECTORY = "runs";
213
214
  /** Bounds a file-store listing page by default. */
214
215
  var BROWSER_FILE_STORE_LIMIT = 100;
216
+ /** Names the refusal when no browser can serve a call. */
217
+ var BROWSER_SERVER_UNAVAILABLE = "BROWSER_SERVER_UNAVAILABLE";
218
+ /** Names a failed attempt to warm a browser. */
219
+ var BROWSER_SERVER_LAUNCH = "BROWSER_SERVER_LAUNCH";
220
+ /** Sets the default number of warm browsers to 1. */
221
+ var BROWSER_SERVER_POOL_SIZE = 1;
222
+ /** Limits the number of warm browsers to 3. */
223
+ var BROWSER_SERVER_POOL_LIMIT = 3;
224
+ /** Permits one failed refill before the next failure spends the bound. */
225
+ var BROWSER_SERVER_RESTARTS = 1;
226
+ /** Names the browser process record in each profile. */
227
+ var BROWSER_SERVER_RECORD = "browse.json";
228
+ /** Names the diagnostic when the warm floor spends its restart bound. */
229
+ var BROWSER_SERVER_EXHAUSTED = "BROWSER_SERVER_EXHAUSTED";
230
+ /** Names a failed browser server teardown. */
231
+ var BROWSER_SERVER_TEARDOWN = "BROWSER_SERVER_TEARDOWN";
232
+ /** Names a failed profile sweep. */
233
+ var BROWSER_SERVER_SWEEP = "BROWSER_SERVER_SWEEP";
234
+ /** Names a refused browser server option. */
235
+ var BROWSER_SERVER_OPTIONS = "BROWSER_SERVER_OPTIONS";
236
+ /** Names the notice that a browser and its session state were lost. */
237
+ var BROWSER_SERVER_CRASH = "BROWSER_SERVER_CRASH";
238
+ /** Names an interrupted call whose outcome is unknown. */
239
+ var BROWSER_SERVER_UNRESOLVED = "BROWSER_SERVER_UNRESOLVED";
215
240
  //#endregion
216
241
  //#region src/server/errors.ts
217
242
  /**
@@ -255,6 +280,58 @@ function isBrowserDestroyedError(value) {
255
280
  //#endregion
256
281
  //#region src/server/helpers.ts
257
282
  /**
283
+ * Probes whether a process has not been confirmed absent.
284
+ * @param pid - Process identifier to probe with signal zero
285
+ * @returns True if the host has not confirmed absence with ESRCH; false otherwise
286
+ * @example
287
+ * probeProcess(process.pid) // true
288
+ */
289
+ function probeProcess(pid) {
290
+ try {
291
+ process.kill(pid, 0);
292
+ return true;
293
+ } catch (error) {
294
+ return !((0, _orkestrel_contract.isError)(error) && "code" in error && error.code === "ESRCH");
295
+ }
296
+ }
297
+ /**
298
+ * Parses a profile record naming a positive process identifier and a loopback DevTools endpoint.
299
+ * @param text - JSON record read from a browse profile
300
+ * @returns The record, or undefined when its shape or endpoint is refused
301
+ * @example
302
+ * parseBrowserProfileRecord('{"pid":123,"endpoint":"ws://127.0.0.1:9222/devtools/browser/session"}')
303
+ */
304
+ function parseBrowserProfileRecord(text) {
305
+ const record = (0, _orkestrel_contract.parseJSON)(text);
306
+ if (!(0, _orkestrel_contract.isRecord)(record)) return void 0;
307
+ const pid = record["pid"];
308
+ const endpoint = record["endpoint"];
309
+ if (!(0, _orkestrel_contract.isNumber)(pid) || !Number.isSafeInteger(pid) || pid <= 0 || !(0, _orkestrel_contract.isString)(endpoint)) return void 0;
310
+ const match = /^ws:\/\/127\.0\.0\.1:([1-9]\d*)\/devtools\/browser\/[^/?#\s\\]+$/.exec(endpoint);
311
+ if (match === null || Number(match[1]) > 65535) return void 0;
312
+ return {
313
+ pid,
314
+ endpoint
315
+ };
316
+ }
317
+ /**
318
+ * Describes a browser loss with its code, cause, and the session state it invalidates.
319
+ * @param code - Browser server diagnostic code
320
+ * @param cause - The loss or refusal, including its process context when present
321
+ * @param url - The lost page's last URL, when known
322
+ * @returns The coded diagnostic and any operation-lifecycle guidance
323
+ * @example
324
+ * describeBrowserServerLoss('BROWSER_SERVER_UNAVAILABLE', new Error('Browser exited'))
325
+ */
326
+ function describeBrowserServerLoss(code, cause, url) {
327
+ const pid = (0, _src_core.isBrowserError)(cause) ? cause.context?.["pid"] : void 0;
328
+ const message = `${code}: ${(cause === void 0 ? "Browser session lost" : (0, _orkestrel_contract.isError)(cause) ? cause.message : String(cause)).replace(/\.+$/u, "")}${(0, _orkestrel_contract.isNumber)(pid) ? ` (pid ${pid})` : ""}.`;
329
+ if (code !== "BROWSER_SERVER_CRASH" && code !== "BROWSER_SERVER_UNRESOLVED") return message;
330
+ const lost = `Lost the page${url === void 0 ? "" : ` at ${url}`}, its tabs, every element reference, the retained reading, dialogs, holds, an unsaved recording, the active replay, and the isolated context's cookies.`;
331
+ const next = `The next call acquires a browser that starts at about:blank, or answers ${BROWSER_SERVER_UNAVAILABLE} when none can serve.`;
332
+ return code === "BROWSER_SERVER_UNRESOLVED" ? `${message} The outcome is unknown. ${lost} Browse did not repeat the call. ${next}` : `${message} ${lost}`;
333
+ }
334
+ /**
258
335
  * Formats a process identifier and UUID token as a lock entry name.
259
336
  * @param pid - Positive safe integer identifying the holder process
260
337
  * @param token - Lowercase UUID token identifying this acquisition
@@ -868,33 +945,37 @@ var FileBrowserWriter = class {
868
945
  };
869
946
  //#endregion
870
947
  //#region package.json
871
- var version = "0.0.21";
948
+ var version = "0.0.23";
872
949
  //#endregion
873
950
  //#region src/server/BrowserMCPServer.ts
874
951
  /**
875
952
  * Implements `BrowserMCPServerInterface`: serves the browser vocabulary and the journey tools over
876
- * the Model Context Protocol on stdio, and launches Chromium on the first tool call.
953
+ * the Model Context Protocol on stdio, and warms Chromium at server start.
877
954
  *
878
955
  * @remarks
879
- * The server's own manager holds one dispatcher per name of the vocabulary — `look`, `read`,
956
+ * The server's own manager holds one dispatcher per name of the vocabulary — `look`, `read`, `plain`,
880
957
  * `click`, `type`, `press`, `navigate`, `wait`, `dialog`, `tabs`, `switch`, `record`, `save`,
881
- * `journeys`, `edit`, and `replay` — each carrying the description, parameters, and annotations
958
+ * `journeys`, `edit`, `replay`, and `forget` — each carrying the description, parameters, and annotations
882
959
  * `BROWSER_TOOL_COPY` gives, so `tools/list` answers before Chromium starts and with Chromium
883
960
  * absent. After the launch, a tool the toolset's manager adds under another name, such as a page
884
961
  * tool it adopts, is mirrored as a dispatcher with that tool's definition, and the mirror is
885
962
  * removed when the toolset withdraws the tool, so the server's tool list changes and a subscribed
886
- * client is notified. The first call of any dispatcher launches Chromium one time: concurrent callers await
887
- * the same launch, a failed launch rejects every one of them, and the next call launches again.
888
- * A launch creates `ROOT/.profiles/ID/` exclusively, connects a browser that never attaches to an
963
+ * client is notified. Startup warms the pool and leases one validated browser. The legacy
964
+ * handshake and every tool call await that setup; concurrent callers share a replacement lease.
965
+ * A launch creates `ROOT/.profiles/PID-UUID/` exclusively, connects a browser that never attaches to an
889
966
  * existing endpoint, opens one page in an isolated context, and constructs the page toolset with
890
967
  * that context and the file journey and run stores under the root, on a manager the toolset owns.
891
968
  * A dispatcher forwards its arguments and the call's context, signal included, to the toolset's
892
969
  * manager and returns that manager's value, or rejects with its failure message; a text value
893
970
  * reaches the client as one text block rather than as its JSON text.
971
+ * A disconnect or current-page crash retires its slot. A failed call receives a liveness ping;
972
+ * loss makes its outcome unresolved, while a known success remains successful. Pending losses
973
+ * prefix the next outcome on a successor, or a refusal, and calls are never repeated.
894
974
  *
895
975
  * `start()` serves stdio and destroys the server at the end of its input, on `SIGINT`, and on
896
- * `SIGTERM`. `destroy()` stops reading requests and removes every dispatcher, then destroys the
897
- * toolset, which aborts the active replay, then the browser it launched, then removes the profile.
976
+ * `SIGTERM`. `destroy()` stops reading requests and removes every dispatcher, then destroys every
977
+ * slot through the pool — its toolset, which aborts the active replay, then its browser, then its
978
+ * profile — and rechecks the profile folders this server made or kept.
898
979
  *
899
980
  * Chromium on Linux refuses to start as the root user unless its sandbox is disabled, so a server
900
981
  * running as root on Linux launches Chromium with `--no-sandbox`; a launch that could run
@@ -924,11 +1005,21 @@ var BrowserMCPServer = class {
924
1005
  #added;
925
1006
  #removed;
926
1007
  #cleared;
927
- #mirrored;
928
- #session;
929
- #browser;
930
- #profile;
931
- #started = false;
1008
+ #pool;
1009
+ #log;
1010
+ #folders = /* @__PURE__ */ new Set();
1011
+ #faults = /* @__PURE__ */ new Set();
1012
+ #losses = /* @__PURE__ */ new WeakMap();
1013
+ #watches = /* @__PURE__ */ new Map();
1014
+ #notice = /* @__PURE__ */ new Set();
1015
+ #reference;
1016
+ #references = 0;
1017
+ #failure;
1018
+ #stranded;
1019
+ #lease;
1020
+ #granting;
1021
+ #starting;
1022
+ #sweeping;
932
1023
  #closing;
933
1024
  /**
934
1025
  * Registers the dispatchers and binds the protocol to the stdio streams without reading them.
@@ -936,6 +1027,19 @@ var BrowserMCPServer = class {
936
1027
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
937
1028
  */
938
1029
  constructor(options) {
1030
+ const size = options?.pool?.size ?? 1;
1031
+ if (!(0, _orkestrel_contract.isInteger)(size) || size < 1 || size > 3) throw new _src_core.BrowserError(`pool.size must be an integer from 1 through 3`, BROWSER_SERVER_OPTIONS);
1032
+ this.#log = options?.log ?? process.stderr;
1033
+ this.#reference = this.#issue.bind(this);
1034
+ this.#pool = (0, _orkestrel_pool.createPool)({
1035
+ create: this.#warm.bind(this),
1036
+ destroy: (slot) => this.#destroySlot(slot.profile, slot.browser, slot.toolset, this.#losses.get(slot)?.cause),
1037
+ validate: this.#validate.bind(this),
1038
+ watch: this.#watch.bind(this),
1039
+ error: this.#fault.bind(this),
1040
+ min: size,
1041
+ restarts: 1
1042
+ });
939
1043
  this.#root = (0, node_path.resolve)(options?.root ?? "tmp/browsers");
940
1044
  this.#headless = options?.headless ?? true;
941
1045
  this.#executable = options?.executable;
@@ -955,7 +1059,8 @@ var BrowserMCPServer = class {
955
1059
  version
956
1060
  },
957
1061
  tools: this.#tools,
958
- execution: this.#execute.bind(this)
1062
+ execution: this.#execute.bind(this),
1063
+ handshake: this.#handshake.bind(this)
959
1064
  });
960
1065
  this.#transport = (0, _orkestrel_mcp_server.createStdioServer)((0, _orkestrel_mcp.createMCPLegacy)(server), {
961
1066
  input: this.#input,
@@ -968,17 +1073,32 @@ var BrowserMCPServer = class {
968
1073
  }
969
1074
  async start() {
970
1075
  if (this.#closing !== void 0) throw this.#ended();
971
- if (this.#started) return;
972
- this.#started = true;
973
- this.#transport.start();
974
- this.#input.on("end", this.#signal);
975
- process.on("SIGINT", this.#signal);
976
- process.on("SIGTERM", this.#signal);
1076
+ this.#starting ??= this.#setup();
1077
+ return this.#starting;
977
1078
  }
978
1079
  destroy() {
979
- this.#closing ??= this.#destroy();
1080
+ this.#closing ??= Promise.resolve().then(this.#destroy.bind(this));
980
1081
  return this.#closing;
981
1082
  }
1083
+ async #setup() {
1084
+ this.#input.on("end", this.#signal);
1085
+ process.on("SIGINT", this.#signal);
1086
+ process.on("SIGTERM", this.#signal);
1087
+ this.#transport.start();
1088
+ try {
1089
+ await (0, node_fs_promises.mkdir)((0, node_path.join)(this.#root, ".profiles"), { recursive: true });
1090
+ if (this.#closing !== void 0) return;
1091
+ this.#sweeping = this.#sweep();
1092
+ const warming = this.#pool.start().then(void 0, (error) => error);
1093
+ await this.#grant(this.#abort.signal);
1094
+ if (this.#closing !== void 0) return;
1095
+ warming.then(this.#exhaust.bind(this));
1096
+ } catch (error) {
1097
+ if (this.#closing !== void 0) return;
1098
+ if ((0, _src_core.isBrowserError)(error) && error.code === "BROWSER_SERVER_UNAVAILABLE") throw error;
1099
+ throw this.#unavailable((0, _orkestrel_pool.isPoolError)(error) ? error.cause ?? this.#failure : error);
1100
+ }
1101
+ }
982
1102
  async #destroy() {
983
1103
  process.removeListener("SIGINT", this.#signal);
984
1104
  process.removeListener("SIGTERM", this.#signal);
@@ -986,19 +1106,34 @@ var BrowserMCPServer = class {
986
1106
  this.#transport.stop();
987
1107
  for (const tool of this.#tools.tools()) this.#tools.remove(tool.name);
988
1108
  this.#unmirror();
1109
+ this.#lease = void 0;
989
1110
  this.#abort.abort();
990
- const failures = [];
991
- await (await this.#session?.catch(() => void 0))?.destroy().catch((error) => failures.push(error));
992
- await this.#browser?.destroy().catch((error) => failures.push(error));
993
- this.#browser = void 0;
994
- const profile = this.#profile;
995
- this.#profile = void 0;
996
- if (profile !== void 0) await (0, node_fs_promises.rm)(profile, {
997
- recursive: true,
998
- force: true,
999
- maxRetries: 5
1111
+ const barrier = this.#pool.destroy().catch((error) => {
1112
+ for (const failure of (0, _orkestrel_pool.isPoolError)(error) ? error.context?.failures ?? [error] : [error]) this.#faults.add(failure);
1000
1113
  });
1001
- if (failures.length > 0) throw new AggregateError(failures, "The browse server teardown failed");
1114
+ await this.#starting?.catch(() => void 0);
1115
+ await barrier;
1116
+ await this.#sweeping;
1117
+ if (this.#stranded !== void 0) this.#faults.add(this.#stranded);
1118
+ for (const folder of this.#folders) {
1119
+ try {
1120
+ const record = parseBrowserProfileRecord(await (0, node_fs_promises.readFile)((0, node_path.join)(folder, BROWSER_SERVER_RECORD), "utf8"));
1121
+ if (record === void 0 || probeProcess(record.pid)) continue;
1122
+ } catch (error) {
1123
+ if (!(0, _orkestrel_contract.isError)(error) || !("code" in error) || error.code !== "ENOENT") continue;
1124
+ }
1125
+ try {
1126
+ await (0, node_fs_promises.rm)(folder, {
1127
+ recursive: true,
1128
+ force: true,
1129
+ maxRetries: 5
1130
+ });
1131
+ this.#folders.delete(folder);
1132
+ } catch (error) {
1133
+ this.#faults.add(error);
1134
+ }
1135
+ }
1136
+ if (this.#faults.size > 0) throw new AggregateError([...this.#faults], "The browse server teardown failed");
1002
1137
  }
1003
1138
  async #execute(context) {
1004
1139
  const result = await context.tools.execute(context.call, {
@@ -1015,38 +1150,224 @@ var BrowserMCPServer = class {
1015
1150
  };
1016
1151
  }
1017
1152
  #end() {
1018
- this.destroy();
1153
+ if (this.#closing !== void 0) return;
1154
+ this.destroy().catch(this.#endFailure.bind(this));
1155
+ }
1156
+ #endFailure(error) {
1157
+ process.exitCode = 1;
1158
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1019
1159
  }
1020
1160
  async #forward(name, args, context) {
1021
- const result = await (await this.#open()).tools.execute({
1022
- id: name,
1023
- name,
1024
- arguments: args
1025
- }, context);
1026
- if (!result.success) throw new _src_core.BrowserError(result.error);
1027
- return result.value;
1028
- }
1029
- #open() {
1030
- if (this.#closing !== void 0) return Promise.reject(this.#ended());
1031
- const current = this.#session;
1032
- if (current !== void 0) return current;
1033
- const session = this.#launch();
1034
- this.#session = session;
1035
- session.catch(() => {
1036
- if (this.#session === session) this.#session = void 0;
1161
+ let slot;
1162
+ try {
1163
+ slot = await this.#serve(context.signal);
1164
+ } catch (error) {
1165
+ context.signal.throwIfAborted();
1166
+ if ((0, _src_core.isBrowserError)(error)) throw new _src_core.BrowserError(this.#annotate(`${error.code}: ${error.message}`), error.code);
1167
+ throw error;
1168
+ }
1169
+ let result;
1170
+ try {
1171
+ result = await slot.toolset.tools.execute({
1172
+ id: name,
1173
+ name,
1174
+ arguments: args
1175
+ }, context);
1176
+ } catch (error) {
1177
+ result = {
1178
+ id: name,
1179
+ name,
1180
+ success: false,
1181
+ error: (0, _orkestrel_contract.isError)(error) ? error.message : String(error)
1182
+ };
1183
+ }
1184
+ context.signal.throwIfAborted();
1185
+ if (!result.success) {
1186
+ if (this.#lease?.value === slot && !context.signal.aborted) try {
1187
+ await slot.browser.ping({ signal: AbortSignal.any([context.signal, this.#abort.signal]) });
1188
+ } catch (error) {
1189
+ if (!context.signal.aborted && !this.#abort.signal.aborted) this.#lose(slot, error);
1190
+ }
1191
+ context.signal.throwIfAborted();
1192
+ if (this.#lease?.value !== slot) {
1193
+ const loss = this.#losses.get(slot);
1194
+ throw new _src_core.BrowserError(this.#annotate(describeBrowserServerLoss(BROWSER_SERVER_UNRESOLVED, loss?.cause, loss?.url), slot), BROWSER_SERVER_UNRESOLVED);
1195
+ }
1196
+ throw new _src_core.BrowserError(this.#annotate(result.error, slot));
1197
+ }
1198
+ return typeof result.value === "string" ? this.#annotate(result.value, slot) : result.value;
1199
+ }
1200
+ #annotate(value, slot) {
1201
+ const notes = [];
1202
+ for (const lost of this.#notice) {
1203
+ if (lost === slot) continue;
1204
+ const loss = this.#losses.get(lost);
1205
+ notes.push(describeBrowserServerLoss(BROWSER_SERVER_CRASH, loss?.cause, loss?.url));
1206
+ this.#notice.delete(lost);
1207
+ }
1208
+ return [...notes, value].join("\n");
1209
+ }
1210
+ async #handshake(options) {
1211
+ try {
1212
+ await this.#race(this.#starting, options.signal);
1213
+ if (this.#closing !== void 0) throw this.#ended();
1214
+ } catch (error) {
1215
+ options.signal.throwIfAborted();
1216
+ if ((0, _src_core.isBrowserError)(error)) throw new _orkestrel_mcp.MCPError(`${error.code}: ${error.message}`, _orkestrel_mcp.JSONRPC_SERVER_ERROR, { code: error.code });
1217
+ throw error;
1218
+ }
1219
+ }
1220
+ async #race(promise, signal) {
1221
+ signal.throwIfAborted();
1222
+ const aborted = Promise.withResolvers();
1223
+ const listener = (0, node_events.addAbortListener)(signal, () => aborted.reject(signal.reason));
1224
+ try {
1225
+ return await Promise.race([promise, aborted.promise]);
1226
+ } finally {
1227
+ listener[Symbol.dispose]();
1228
+ }
1229
+ }
1230
+ async #grant(signal) {
1231
+ if (this.#lease !== void 0) return this.#lease;
1232
+ this.#granting ??= this.#pool.acquire(this.#abort.signal).then(this.#hold.bind(this), this.#refuse.bind(this));
1233
+ const token = await this.#race(this.#granting, signal);
1234
+ if (token === void 0) throw this.#ended();
1235
+ return token;
1236
+ }
1237
+ #hold(token) {
1238
+ this.#granting = void 0;
1239
+ if (this.#closing !== void 0) throw this.#ended();
1240
+ if (this.#losses.has(token.value)) {
1241
+ token.destroy().catch(() => void 0);
1242
+ throw this.#unavailable(this.#losses.get(token.value)?.cause);
1243
+ }
1244
+ this.#lease = token;
1245
+ const tools = token.value.toolset.tools;
1246
+ for (const tool of tools.tools()) this.#mirror(tool);
1247
+ tools.emitter.on("add", this.#added);
1248
+ tools.emitter.on("remove", this.#removed);
1249
+ tools.emitter.on("clear", this.#cleared);
1250
+ return token;
1251
+ }
1252
+ #refuse(error) {
1253
+ this.#granting = void 0;
1254
+ throw error;
1255
+ }
1256
+ async #serve(signal) {
1257
+ if (this.#closing !== void 0) throw this.#ended();
1258
+ await this.#race(this.#starting, signal);
1259
+ if (this.#closing !== void 0) throw this.#ended();
1260
+ const held = this.#lease;
1261
+ if (held !== void 0) try {
1262
+ await held.value.browser.ping({ signal: AbortSignal.any([signal, this.#abort.signal]) });
1263
+ if (this.#lease === held) return held.value;
1264
+ } catch (error) {
1265
+ signal.throwIfAborted();
1266
+ if (this.#abort.signal.aborted) throw this.#ended();
1267
+ this.#lose(held.value, error);
1268
+ }
1269
+ try {
1270
+ const token = await this.#grant(signal);
1271
+ if (this.#lease !== token) throw this.#unavailable(this.#losses.get(token.value)?.cause);
1272
+ return token.value;
1273
+ } catch (error) {
1274
+ signal.throwIfAborted();
1275
+ if (this.#abort.signal.aborted || (0, _orkestrel_pool.isPoolError)(error) && error.code === "destroyed") throw this.#ended();
1276
+ if ((0, _orkestrel_pool.isPoolError)(error) && (error.code === "create" || error.code === "cleanup")) {
1277
+ this.#exhaust(error);
1278
+ throw this.#unavailable(error.cause ?? this.#failure);
1279
+ }
1280
+ throw error;
1281
+ }
1282
+ }
1283
+ async #validate(slot) {
1284
+ try {
1285
+ await slot.browser.ping({ signal: this.#abort.signal });
1286
+ return true;
1287
+ } catch (error) {
1288
+ if (!this.#abort.signal.aborted) this.#lose(slot, error);
1289
+ return false;
1290
+ }
1291
+ }
1292
+ #lose(slot, cause) {
1293
+ if (this.#closing !== void 0 || this.#losses.has(slot)) return;
1294
+ this.#losses.set(slot, {
1295
+ cause,
1296
+ url: slot.toolset.view.url
1297
+ });
1298
+ this.#failure = cause;
1299
+ if (this.#lease?.value !== slot) return;
1300
+ const token = this.#lease;
1301
+ this.#unmirror();
1302
+ this.#lease = void 0;
1303
+ this.#notice.add(slot);
1304
+ token.destroy().catch(() => void 0);
1305
+ }
1306
+ #watch(slot, signal) {
1307
+ const loss = Promise.withResolvers();
1308
+ const disconnect = this.#disconnect.bind(this, slot);
1309
+ const page = this.#track.bind(this, slot);
1310
+ const subscription = (0, node_events.addAbortListener)(signal, this.#unwatch.bind(this, slot));
1311
+ this.#watches.set(slot, {
1312
+ resolve: loss.resolve,
1313
+ disconnect,
1314
+ page,
1315
+ subscription,
1316
+ crashes: /* @__PURE__ */ new Map()
1317
+ });
1318
+ slot.browser.emitter.on("disconnect", disconnect);
1319
+ slot.context.emitter.on("page", page);
1320
+ for (const current of slot.context.pages()) this.#track(slot, current);
1321
+ if (signal.aborted) this.#unwatch(slot);
1322
+ else if (slot.browser.status !== "connected") this.#disconnect(slot);
1323
+ return loss.promise;
1324
+ }
1325
+ #track(slot, page) {
1326
+ const watch = this.#watches.get(slot);
1327
+ if (watch === void 0 || watch.crashes.has(page)) return;
1328
+ const crash = this.#createCrash(slot, page);
1329
+ page.emitter.on("crash", crash);
1330
+ this.#watches.set(slot, {
1331
+ ...watch,
1332
+ crashes: new Map([...watch.crashes, [page, crash]])
1037
1333
  });
1038
- return session;
1039
1334
  }
1040
- async #launch() {
1041
- await (0, node_fs_promises.mkdir)(this.#root, { recursive: true });
1042
- const profiles = (0, node_path.join)(this.#root, ".profiles");
1043
- await (0, node_fs_promises.mkdir)(profiles, { recursive: true });
1044
- const profile = (0, node_path.join)(profiles, (0, node_crypto.randomUUID)());
1045
- await (0, node_fs_promises.mkdir)(profile);
1046
- this.#profile = profile;
1335
+ #createCrash(slot, page) {
1336
+ return () => {
1337
+ if (page === slot.toolset.view) this.#report(slot, /* @__PURE__ */ new Error("The current page renderer crashed"));
1338
+ };
1339
+ }
1340
+ #disconnect(slot) {
1341
+ this.#report(slot, /* @__PURE__ */ new Error(slot.browser.pid === void 0 ? "The browser process exited" : "The browser transport disconnected"));
1342
+ }
1343
+ #report(slot, cause) {
1344
+ const watch = this.#watches.get(slot);
1345
+ if (watch === void 0) return;
1346
+ this.#unwatch(slot);
1347
+ this.#lose(slot, cause);
1348
+ watch.resolve(cause);
1349
+ }
1350
+ #unwatch(slot) {
1351
+ const watch = this.#watches.get(slot);
1352
+ if (watch === void 0) return;
1353
+ this.#watches.delete(slot);
1354
+ slot.browser.emitter.off("disconnect", watch.disconnect);
1355
+ slot.context.emitter.off("page", watch.page);
1356
+ for (const [page, crash] of watch.crashes) page.emitter.off("crash", crash);
1357
+ watch.subscription[Symbol.dispose]();
1358
+ }
1359
+ #fault(error) {
1360
+ this.#faults.add(error);
1361
+ }
1362
+ async #warm() {
1363
+ if (this.#closing !== void 0) throw this.#ended();
1364
+ if (this.#stranded !== void 0) throw new _src_core.BrowserError((0, _orkestrel_contract.isError)(this.#stranded) ? this.#stranded.message : String(this.#stranded), BROWSER_SERVER_TEARDOWN, (0, _src_core.isBrowserError)(this.#stranded) ? this.#stranded.context : void 0);
1365
+ const profile = (0, node_path.join)(this.#root, ".profiles", formatBrowserLockEntry(process.pid, (0, node_crypto.randomUUID)()));
1047
1366
  let browser;
1048
1367
  let toolset;
1049
1368
  try {
1369
+ await (0, node_fs_promises.mkdir)(profile);
1370
+ this.#folders.add(profile);
1050
1371
  browser = this.#launcher({
1051
1372
  headless: this.#headless,
1052
1373
  profile,
@@ -1055,9 +1376,16 @@ var BrowserMCPServer = class {
1055
1376
  ...this.#executable === void 0 ? {} : { executable: this.#executable },
1056
1377
  ...process.platform === "linux" && process.getuid?.() === 0 ? { args: ["--no-sandbox"] } : {}
1057
1378
  });
1058
- this.#browser = browser;
1059
1379
  await browser.connect();
1060
- const context = await browser.isolate();
1380
+ if (browser.pid !== void 0 && browser.endpoint !== void 0) {
1381
+ const record = (0, node_path.join)(profile, BROWSER_SERVER_RECORD);
1382
+ await (0, node_fs_promises.writeFile)(`${record}.tmp`, JSON.stringify({
1383
+ pid: browser.pid,
1384
+ endpoint: browser.endpoint
1385
+ }));
1386
+ await (0, node_fs_promises.rename)(`${record}.tmp`, record);
1387
+ }
1388
+ const context = await browser.isolate({ reference: this.#reference });
1061
1389
  const page = await context.create();
1062
1390
  toolset = (0, _src_core.createBrowserToolset)(page, {
1063
1391
  context,
@@ -1067,26 +1395,103 @@ var BrowserMCPServer = class {
1067
1395
  readonly: this.#readonly
1068
1396
  }
1069
1397
  });
1070
- this.#mirrored = toolset.tools;
1071
- toolset.tools.emitter.on("add", this.#added);
1072
- toolset.tools.emitter.on("remove", this.#removed);
1073
- toolset.tools.emitter.on("clear", this.#cleared);
1074
1398
  await toolset.start();
1075
- return toolset;
1399
+ return {
1400
+ browser,
1401
+ profile,
1402
+ context,
1403
+ toolset
1404
+ };
1076
1405
  } catch (error) {
1077
- this.#unmirror();
1078
- if (this.#browser === browser) this.#browser = void 0;
1079
- if (this.#profile === profile) this.#profile = void 0;
1080
- await toolset?.destroy().catch(() => void 0);
1081
- await browser?.destroy().catch(() => void 0);
1406
+ if (this.#folders.has(profile)) await this.#destroySlot(profile, browser, toolset).catch((failure) => {
1407
+ this.#stranded = failure;
1408
+ });
1409
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_LAUNCH, error)}`);
1410
+ throw error;
1411
+ }
1412
+ }
1413
+ async #destroySlot(profile, browser, toolset, cause) {
1414
+ if ((0, _src_core.isCDPTimeoutError)(cause) && browser?.pid !== void 0) try {
1415
+ process.kill(browser.pid, "SIGKILL");
1416
+ } catch (error) {
1417
+ if (!(0, _orkestrel_contract.isError)(error) || !("code" in error) || error.code !== "ESRCH") this.#faults.add(error);
1418
+ }
1419
+ await toolset?.destroy().catch((error) => this.#faults.add(error));
1420
+ try {
1421
+ await browser?.destroy();
1422
+ } catch (error) {
1423
+ if (this.#closing === void 0) this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_TEARDOWN, error)}`);
1424
+ throw error;
1425
+ }
1426
+ try {
1082
1427
  await (0, node_fs_promises.rm)(profile, {
1083
1428
  recursive: true,
1084
1429
  force: true,
1085
1430
  maxRetries: 5
1086
1431
  });
1087
- throw error;
1432
+ this.#folders.delete(profile);
1433
+ } catch {}
1434
+ }
1435
+ async #sweep() {
1436
+ const profiles = (0, node_path.join)(this.#root, ".profiles");
1437
+ try {
1438
+ for (const entry of await (0, node_fs_promises.readdir)(profiles)) {
1439
+ if (this.#closing !== void 0) return;
1440
+ const pid = parseBrowserLockEntry(entry);
1441
+ if (pid === void 0 || pid === process.pid || probeProcess(pid)) continue;
1442
+ const folder = (0, node_path.join)(profiles, entry);
1443
+ this.#folders.add(folder);
1444
+ try {
1445
+ const record = parseBrowserProfileRecord(await (0, node_fs_promises.readFile)((0, node_path.join)(folder, BROWSER_SERVER_RECORD), "utf8"));
1446
+ if (record === void 0) continue;
1447
+ if (probeProcess(record.pid)) {
1448
+ const transport = createCDPTransport({ url: record.endpoint });
1449
+ const client = (0, _src_core.createCDPClient)({ transport });
1450
+ try {
1451
+ this.#abort.signal.throwIfAborted();
1452
+ await this.#race(client.connect(), this.#abort.signal);
1453
+ await client.send("Browser.close", void 0, { signal: this.#abort.signal });
1454
+ } catch {} finally {
1455
+ await transport.close();
1456
+ await client.close();
1457
+ }
1458
+ if (probeProcess(record.pid)) continue;
1459
+ }
1460
+ } catch (error) {
1461
+ if (!(0, _orkestrel_contract.isError)(error) || !("code" in error) || error.code !== "ENOENT") continue;
1462
+ }
1463
+ try {
1464
+ await (0, node_fs_promises.rm)(folder, {
1465
+ recursive: true,
1466
+ force: true,
1467
+ maxRetries: 5
1468
+ });
1469
+ this.#folders.delete(folder);
1470
+ } catch {}
1471
+ }
1472
+ } catch (error) {
1473
+ this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_SWEEP, error)}`);
1088
1474
  }
1089
1475
  }
1476
+ #write(line) {
1477
+ try {
1478
+ this.#log.write(`${line}\n`, (error) => {
1479
+ if (error !== void 0 && error !== null) this.#faults.add(error);
1480
+ });
1481
+ } catch (error) {
1482
+ this.#faults.add(error);
1483
+ }
1484
+ }
1485
+ #exhaust(error) {
1486
+ if ((0, _orkestrel_pool.isPoolError)(error) && error.code === "create") this.#write(`browse: ${describeBrowserServerLoss(BROWSER_SERVER_EXHAUSTED, error.cause ?? this.#failure)}`);
1487
+ }
1488
+ #unavailable(cause) {
1489
+ const message = describeBrowserServerLoss(BROWSER_SERVER_UNAVAILABLE, cause);
1490
+ return new _src_core.BrowserError(message.slice(BROWSER_SERVER_UNAVAILABLE.length + 2), BROWSER_SERVER_UNAVAILABLE);
1491
+ }
1492
+ #issue() {
1493
+ return `e${++this.#references}`;
1494
+ }
1090
1495
  #mirror(tool) {
1091
1496
  if (this.#vocabulary.has(tool.name)) return;
1092
1497
  this.#tools.add((0, _orkestrel_tool.createTool)({
@@ -1096,7 +1501,7 @@ var BrowserMCPServer = class {
1096
1501
  }
1097
1502
  #withdraw(tool) {
1098
1503
  if (this.#vocabulary.has(tool.name)) return;
1099
- const current = this.#mirrored?.tool(tool.name);
1504
+ const current = this.#lease?.value.toolset.tools.tool(tool.name);
1100
1505
  if (current === void 0) this.#tools.remove(tool.name);
1101
1506
  else this.#mirror(current);
1102
1507
  }
@@ -1104,9 +1509,9 @@ var BrowserMCPServer = class {
1104
1509
  for (const tool of tools) this.#withdraw(tool);
1105
1510
  }
1106
1511
  #unmirror() {
1107
- const mirrored = this.#mirrored;
1512
+ const mirrored = this.#lease?.value.toolset.tools;
1108
1513
  if (mirrored === void 0) return;
1109
- this.#mirrored = void 0;
1514
+ for (const tool of mirrored.tools()) if (!this.#vocabulary.has(tool.name)) this.#tools.remove(tool.name);
1110
1515
  mirrored.emitter.off("add", this.#added);
1111
1516
  mirrored.emitter.off("remove", this.#removed);
1112
1517
  mirrored.emitter.off("clear", this.#cleared);
@@ -1228,7 +1633,13 @@ var FileBrowserStore = class {
1228
1633
  await this.check(path, options);
1229
1634
  await this.check(temporary, options);
1230
1635
  options?.signal?.throwIfAborted();
1231
- await (0, node_fs_promises.rename)(temporary, path);
1636
+ try {
1637
+ await (0, node_fs_promises.rename)(temporary, path);
1638
+ } catch (error) {
1639
+ const target = await (0, node_fs_promises.lstat)(path).catch(() => void 0);
1640
+ if (error instanceof Error && "code" in error && (error.code === "EISDIR" || error.code === "EPERM") && target?.isDirectory()) throw new _src_core.BrowserError(`Cannot replace directory: ${path}`, "BROWSER_JOURNEY_FILE", { path });
1641
+ throw error;
1642
+ }
1232
1643
  owned = false;
1233
1644
  } catch (error) {
1234
1645
  options?.signal?.throwIfAborted();
@@ -1273,13 +1684,7 @@ var FileBrowserStore = class {
1273
1684
  const holder = entries[0];
1274
1685
  if (holder !== void 0) {
1275
1686
  const pid = parseBrowserLockEntry(holder);
1276
- if (pid === void 0) break;
1277
- try {
1278
- process.kill(pid, 0);
1279
- break;
1280
- } catch (cause) {
1281
- if (!(cause instanceof Error && "code" in cause && cause.code === "ESRCH")) break;
1282
- }
1687
+ if (pid === void 0 || probeProcess(pid)) break;
1283
1688
  if (!await this.#unlinkLock(this.resolvePath(path, holder))) continue;
1284
1689
  }
1285
1690
  await this.#removeLock(path);
@@ -1429,7 +1834,14 @@ var FileBrowserStore = class {
1429
1834
  try {
1430
1835
  await (0, node_fs_promises.rmdir)(path);
1431
1836
  } catch (error) {
1432
- if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTEMPTY")) return;
1837
+ if (error instanceof Error && "code" in error) {
1838
+ if (error.code === "ENOTEMPTY") return;
1839
+ if (error.code === "ENOENT" || error.code === "EPERM") try {
1840
+ if ((await (0, node_fs_promises.lstat)(path)).isDirectory()) return;
1841
+ } catch (cause) {
1842
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return;
1843
+ }
1844
+ }
1433
1845
  throw new _src_core.BrowserError(`Cannot remove lock directory: ${path}`, "BROWSER_JOURNEY_ACCESS", { path });
1434
1846
  }
1435
1847
  }
@@ -1714,7 +2126,7 @@ function createFileBrowserRunStore(options) {
1714
2126
  }
1715
2127
  /**
1716
2128
  * Creates the browse server, which serves the browser vocabulary and the journey tools over the
1717
- * Model Context Protocol on stdio and launches Chromium on the first tool call.
2129
+ * Model Context Protocol on stdio and warms Chromium at server start.
1718
2130
  *
1719
2131
  * @param options - The root, the launch, the journeys' read-only switch, and the streams
1720
2132
  * @returns A {@link BrowserMCPServerInterface} that reads nothing until `start()`
@@ -1724,7 +2136,7 @@ function createFileBrowserRunStore(options) {
1724
2136
  * import { createBrowserMCPServer } from '@orkestrel/browser/server'
1725
2137
  *
1726
2138
  * const server = createBrowserMCPServer({ root: 'tmp/browsers', headless: true })
1727
- * await server.start() // answers tools/list before Chromium starts
2139
+ * await server.start() // resolves after leasing a connected browser
1728
2140
  * await server.destroy()
1729
2141
  * ```
1730
2142
  */
@@ -1800,6 +2212,15 @@ var Browser = class {
1800
2212
  get pid() {
1801
2213
  return this.#servingPid ?? this.#process?.pid;
1802
2214
  }
2215
+ get endpoint() {
2216
+ return this.#endpoint;
2217
+ }
2218
+ async ping(options) {
2219
+ if (this.#destroyed) throw new BrowserDestroyedError();
2220
+ const client = this.#client;
2221
+ if (this.#status !== "connected" || client === void 0) throw new BrowserNotConnectedError();
2222
+ await client.send("Browser.getVersion", void 0, options);
2223
+ }
1803
2224
  async discover() {
1804
2225
  const result = await this.#discoverCDP();
1805
2226
  this.#emitter.emit("discover", result);
@@ -1925,7 +2346,7 @@ var Browser = class {
1925
2346
  await this.#connectCDP(discovery.endpoint);
1926
2347
  return;
1927
2348
  }
1928
- } else await this.#raceAbort(this.#assertPortFree());
2349
+ } else if (this.#options.cdp?.port !== void 0) await this.#raceAbort(this.#assertPortFree());
1929
2350
  this.#assertNotAborted();
1930
2351
  await this.#launch();
1931
2352
  } catch (error) {
@@ -2354,6 +2775,7 @@ var Browser = class {
2354
2775
  const process = this.#process;
2355
2776
  if (process !== void 0) {
2356
2777
  if (!await this.#waitForTerminationWithin(process, 3e3)) await this.#terminate(process);
2778
+ await this.#closeProcessPipe(process);
2357
2779
  }
2358
2780
  this.#process = void 0;
2359
2781
  this.#servingPid = void 0;
@@ -2525,12 +2947,23 @@ var Browser = class {
2525
2947
  return results[0] && (results[1] === true || final && results[1] === void 0);
2526
2948
  }
2527
2949
  async #terminate(process) {
2528
- if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectRemainder(process) === false) return;
2529
- if (this.#signalProcess(process, "SIGTERM") === false) return;
2530
- if (await this.#waitForTerminationWithin(process, 3e3)) return;
2531
- if (this.#signalProcess(process, "SIGKILL") === false) return;
2532
- if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
2533
- throw new _src_core.BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
2950
+ try {
2951
+ if (process === void 0 || (process.exitCode !== null || process.signalCode !== null) && this.#inspectRemainder(process) === false) return;
2952
+ if (this.#signalProcess(process, "SIGTERM") === false) return;
2953
+ if (await this.#waitForTerminationWithin(process, 3e3)) return;
2954
+ if (this.#signalProcess(process, "SIGKILL") === false) return;
2955
+ if (await this.#waitForTerminationWithin(process, 3e3, true)) return;
2956
+ throw new _src_core.BrowserConnectionError("Browser process did not exit after SIGKILL", { pid: this.#servingPid ?? process.pid });
2957
+ } finally {
2958
+ await this.#closeProcessPipe(process);
2959
+ }
2960
+ }
2961
+ async #closeProcessPipe(process) {
2962
+ const stream = process?.stderr;
2963
+ if (stream === void 0 || stream === null || stream.closed) return;
2964
+ const closed = (0, node_events.once)(stream, "close");
2965
+ stream.destroy();
2966
+ await closed;
2534
2967
  }
2535
2968
  #finish() {
2536
2969
  this.#unbindTransport();
@@ -2573,6 +3006,18 @@ exports.BROWSER_PROCESS_EXIT_CAUSE = BROWSER_PROCESS_EXIT_CAUSE;
2573
3006
  exports.BROWSER_PROFILE_PREFIX = BROWSER_PROFILE_PREFIX;
2574
3007
  exports.BROWSER_RUN_DIRECTORY = BROWSER_RUN_DIRECTORY;
2575
3008
  exports.BROWSER_RUN_FILE = BROWSER_RUN_FILE;
3009
+ exports.BROWSER_SERVER_CRASH = BROWSER_SERVER_CRASH;
3010
+ exports.BROWSER_SERVER_EXHAUSTED = BROWSER_SERVER_EXHAUSTED;
3011
+ exports.BROWSER_SERVER_LAUNCH = BROWSER_SERVER_LAUNCH;
3012
+ exports.BROWSER_SERVER_OPTIONS = BROWSER_SERVER_OPTIONS;
3013
+ exports.BROWSER_SERVER_POOL_LIMIT = BROWSER_SERVER_POOL_LIMIT;
3014
+ exports.BROWSER_SERVER_POOL_SIZE = BROWSER_SERVER_POOL_SIZE;
3015
+ exports.BROWSER_SERVER_RECORD = BROWSER_SERVER_RECORD;
3016
+ exports.BROWSER_SERVER_RESTARTS = BROWSER_SERVER_RESTARTS;
3017
+ exports.BROWSER_SERVER_SWEEP = BROWSER_SERVER_SWEEP;
3018
+ exports.BROWSER_SERVER_TEARDOWN = BROWSER_SERVER_TEARDOWN;
3019
+ exports.BROWSER_SERVER_UNAVAILABLE = BROWSER_SERVER_UNAVAILABLE;
3020
+ exports.BROWSER_SERVER_UNRESOLVED = BROWSER_SERVER_UNRESOLVED;
2576
3021
  exports.BROWSER_STORE_CACHE_DIRS = BROWSER_STORE_CACHE_DIRS;
2577
3022
  exports.BROWSER_STORE_DEFAULT_DIRS = BROWSER_STORE_DEFAULT_DIRS;
2578
3023
  exports.BROWSER_STORE_ENV_KEY = BROWSER_STORE_ENV_KEY;
@@ -2601,6 +3046,7 @@ exports.createBrowserWriter = createBrowserWriter;
2601
3046
  exports.createCDPTransport = createCDPTransport;
2602
3047
  exports.createFileBrowserJourneyStore = createFileBrowserJourneyStore;
2603
3048
  exports.createFileBrowserRunStore = createFileBrowserRunStore;
3049
+ exports.describeBrowserServerLoss = describeBrowserServerLoss;
2604
3050
  exports.fetchCDPTargets = fetchCDPTargets;
2605
3051
  exports.findEnvOverrides = findEnvOverrides;
2606
3052
  exports.findInstallPaths = findInstallPaths;
@@ -2614,7 +3060,9 @@ exports.launchBrowserProcess = launchBrowserProcess;
2614
3060
  exports.normalizeExecutablePath = normalizeExecutablePath;
2615
3061
  exports.parseBrowserEngine = parseBrowserEngine;
2616
3062
  exports.parseBrowserLockEntry = parseBrowserLockEntry;
3063
+ exports.parseBrowserProfileRecord = parseBrowserProfileRecord;
2617
3064
  exports.probePathNames = probePathNames;
3065
+ exports.probeProcess = probeProcess;
2618
3066
  exports.readBrowserEndpoint = readBrowserEndpoint;
2619
3067
  exports.readFirstLine = readFirstLine;
2620
3068
  exports.removeBrowserProfile = removeBrowserProfile;