@mind-harness/mh-browser 0.1.2 → 0.1.4

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
@@ -5,12 +5,12 @@ import { isDeepStrictEqual } from "node:util";
5
5
  import { HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
6
6
  import { z as z$1 } from "zod";
7
7
  import { createHash, randomUUID } from "node:crypto";
8
+ import { constants, readFileSync } from "node:fs";
8
9
  import { lstat, mkdir, open, readFile, realpath, unlink, writeFile } from "node:fs/promises";
9
10
  import { parse } from "yaml";
11
+ import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
10
12
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
11
13
  import { chromium } from "playwright";
12
- import { constants, readFileSync } from "node:fs";
13
- import { assertSupportedJsonSchema, defineTool, validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
16
16
  //#region lib/types/config.js
@@ -127,7 +127,7 @@ function presenceSet(state) {
127
127
  }
128
128
  /** The text of a reminder, as `shown` records it. */
129
129
  function textDigest(message) {
130
- return createHash("sha256").update(textOf$2(message)).digest("hex");
130
+ return createHash("sha256").update(textOf$3(message)).digest("hex");
131
131
  }
132
132
  function applySplice(state, event) {
133
133
  const data = record$3(event.data);
@@ -254,7 +254,7 @@ function entryOf$1(value) {
254
254
  ...reason === void 0 ? {} : { reason }
255
255
  }];
256
256
  }
257
- function textOf$2(value) {
257
+ function textOf$3(value) {
258
258
  const content = record$3(value).content;
259
259
  if (!Array.isArray(content)) return "";
260
260
  return content.map((block) => record$3(block).text).filter((text) => typeof text === "string").join("\n");
@@ -279,6 +279,8 @@ var MhBrowserError = class extends Error {
279
279
  if (page !== void 0) this.page = page;
280
280
  }
281
281
  };
282
+ /** Owner of the person's own window. It belongs to no Session. */
283
+ const PERSON_OWNER = "person";
282
284
  const UNKNOWN_TAB = "UNKNOWN_TAB";
283
285
  const UNKNOWN_TAB_REASON = "no such tab in this session";
284
286
  /**
@@ -384,6 +386,65 @@ async function filesInWorkspace(cwd, files) {
384
386
  }
385
387
  return resolved;
386
388
  }
389
+ /** Refuse a filename that leaves the session's write root, without creating it. */
390
+ async function assertInside(target, filename) {
391
+ await place(target, filename);
392
+ }
393
+ /**
394
+ * Write `filename` under the session's write root. A path that leaves the
395
+ * root is refused, through a symbolic link as well: the deepest existing
396
+ * directory is checked before any is created, and the file itself is not
397
+ * followed if it is a link.
398
+ */
399
+ async function writeInside(target, filename, data) {
400
+ const file = await place(target, filename);
401
+ assertUnder(file.root, await realpath(await deepestExisting(dirname(file.file))), filename);
402
+ await mkdir(dirname(file.file), { recursive: true });
403
+ const parent = await realpath(dirname(file.file));
404
+ assertUnder(file.root, parent, filename);
405
+ const placed = resolve(parent, basename(file.file));
406
+ let handle;
407
+ try {
408
+ handle = await open(placed, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW);
409
+ } catch (error) {
410
+ if (error.code === "ELOOP") throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
411
+ throw error;
412
+ }
413
+ try {
414
+ await handle.writeFile(data);
415
+ } finally {
416
+ await handle.close();
417
+ }
418
+ const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
419
+ return {
420
+ path: relative(file.root, placed),
421
+ bytes
422
+ };
423
+ }
424
+ function assertUnder(root, path, filename) {
425
+ const fromRoot = relative(root, path);
426
+ if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
427
+ }
428
+ async function deepestExisting(path) {
429
+ for (let current = path;; current = dirname(current)) try {
430
+ await lstat(current);
431
+ return current;
432
+ } catch {
433
+ if (dirname(current) === current) return current;
434
+ }
435
+ }
436
+ async function place(target, filename) {
437
+ if ("denied" in target) throw new BrowserToolError(target.code, target.denied);
438
+ if (filename.trim() === "") throw new BrowserToolError("INVALID_ARGS", "filename: give a path inside the workspace");
439
+ const root = await realpath(target.root).catch(() => target.root);
440
+ const abs = isAbsolute(filename) ? filename : resolve(root, filename);
441
+ const direct = relative(root, abs);
442
+ if (direct.startsWith("..") || isAbsolute(direct)) throw new BrowserToolError("OUTSIDE_WORKSPACE", `${filename} is outside the session workspace`);
443
+ return {
444
+ root,
445
+ file: abs
446
+ };
447
+ }
387
448
  //#endregion
388
449
  //#region lib/types/tools/attachments.js
389
450
  function initAttachments() {
@@ -452,7 +513,12 @@ function visit(value, depth, found) {
452
513
  }
453
514
  for (const item of Object.values(row)) visit(item, depth + 1, found);
454
515
  }
516
+ //#endregion
517
+ //#region lib/types/representation/agent-view.js
518
+ const AGENT_VIEW_VERSION = "0.1";
455
519
  const AGENT_VIEW_ACCEPT = "text/markdown; variant=agent-view";
520
+ /** Accept on the page address when its origin is known to offer Agent Views. */
521
+ const PAGE_ACCEPT = "text/markdown; variant=agent-view, text/html;q=0.9, */*;q=0.8";
456
522
  const FENCE_OPEN = /```agent-view\b/g;
457
523
  const HTTP_METHODS = /* @__PURE__ */ new Set([
458
524
  "GET",
@@ -659,6 +725,208 @@ function isAbsoluteHttp(uri) {
659
725
  }
660
726
  }
661
727
  //#endregion
728
+ //#region lib/types/render/envelope.js
729
+ /** Every element the service writes around site text. */
730
+ const FRAMES = [
731
+ "system-reminder",
732
+ "page",
733
+ "page-error",
734
+ "added",
735
+ "changed",
736
+ "removed",
737
+ "absent",
738
+ "action-response",
739
+ "tabs",
740
+ "dialog",
741
+ "download",
742
+ "download-error"
743
+ ];
744
+ const CLOSERS = new RegExp(`<\\s*/\\s*(${FRAMES.join("|")})(?![\\w-])`, "gi");
745
+ const OPENERS = new RegExp(`<\\s*(${FRAMES.join("|")})(?![\\w-])`, "gi");
746
+ /**
747
+ * Escape a closer a site could use to end a frame the service opened, and an
748
+ * opener it could use to pass its text off as one. Only `</name` and `<name`
749
+ * are rewritten, so a tag cut off before `>` is caught and no site text is dropped.
750
+ */
751
+ function escapeBody(text) {
752
+ return text.replace(CLOSERS, (_match, tag) => `<\\/${tag.toLowerCase()}`).replace(OPENERS, (_match, tag) => `<\\${tag.toLowerCase()}`);
753
+ }
754
+ /** Escape text the service writes into an attribute. */
755
+ function escapeAttr(text) {
756
+ return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r/g, "&#13;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;");
757
+ }
758
+ /** Site text that must stay on one line, such as a title in `<tabs>`. */
759
+ function oneLine(text) {
760
+ return text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ");
761
+ }
762
+ function incompleteText(incomplete) {
763
+ const pending = `${String(incomplete.pendingRequests)} requests pending`;
764
+ return incomplete.load ? pending : `load not fired; ${pending}`;
765
+ }
766
+ function waitedText(waited) {
767
+ const seconds = formatSeconds(waited.seconds);
768
+ if (waited.met === "text") return `text appeared after ${seconds} s`;
769
+ if (waited.met === "textGone") return `text gone after ${seconds} s`;
770
+ if (waited.met === "time") return `waited ${seconds} s`;
771
+ return `not met after ${seconds} s`;
772
+ }
773
+ /**
774
+ * `withDigest` is for a page attached to a tool error: an error result has no
775
+ * meta, so the digest in the header is what records the delivery.
776
+ */
777
+ function renderPage(view, screenshot, omitBody = false, withDigest = false) {
778
+ const body = view.summary !== void 0 ? view.detail === void 0 || view.detail === "" ? view.summary : `${view.summary}\n${view.detail}` : view.headerOnly ? view.inPresence ? "" : "Open this page to read it or act on it." : omitBody ? "Content follows in the next presence reminder." : pageBody(view);
779
+ const dialog = view.dialog === void 0 ? "" : `\n${renderDialog(view.dialog.type, view.dialog.message)}`;
780
+ const digest = withDigest && !omitBody ? digestOf$1(view) : void 0;
781
+ const digestAttr = digest === void 0 ? "" : attr$1("digest", digest);
782
+ return `<page${pageAttrs(view, screenshot)}${digestAttr}>${escapeBody(body)}${dialog}</page>`;
783
+ }
784
+ function renderActionResponse(response) {
785
+ const body = response.lost ? "No response arrived after sending; the action may have taken effect." : response.body !== void 0 ? response.body : response.bytes !== void 0 ? `${response.mediaType ?? "application/octet-stream"} (${String(response.bytes)} bytes)` : "";
786
+ return `<action-response${attr$1("name", response.name)}${response.lost === true ? attr$1("lost", "true") : ""}${response.status === void 0 ? "" : attr$1("status", String(response.status))}${response.location === void 0 ? "" : attr$1("location", response.location)}>${escapeBody(body)}</action-response>`;
787
+ }
788
+ function renderPageError(error) {
789
+ const tab = error.tab === void 0 ? "" : attr$1("tab", error.tab);
790
+ return `<page-error${attr$1("url", error.url)}${tab}${attr$1("reason", error.reason)}></page-error>`;
791
+ }
792
+ function renderTabs(tabs, closed) {
793
+ const lines = tabs.map((tab) => {
794
+ const title = tab.title === "" ? "" : ` — ${oneLine(tab.title)}`;
795
+ const flags = [
796
+ tab.inPresence ? "in presence" : "",
797
+ tab.mayHaveChanged ? "may have changed" : "",
798
+ tab.occupied ? "in use" : ""
799
+ ].filter((flag) => flag !== "");
800
+ const suffix = flags.length === 0 ? "" : ` ${flags.join(", ")}`;
801
+ return `${tab.tab} ${oneLine(tab.url)}${title}${suffix}`;
802
+ });
803
+ const closedLines = closed.map((note) => `closed: ${note}`);
804
+ return `<tabs>\n${escapeBody([...lines, ...closedLines].join("\n"))}\n</tabs>`;
805
+ }
806
+ function renderDialog(type, message) {
807
+ return `<dialog${attr$1("type", type)}>${escapeBody(message)}</dialog>`;
808
+ }
809
+ function renderDownload(download) {
810
+ if (download.kind === "error") {
811
+ const status = download.status === void 0 ? "" : attr$1("status", String(download.status));
812
+ return `<download-error${attr$1("url", download.url)}${status}${attr$1("reason", download.reason)}></download-error>`;
813
+ }
814
+ const media = download.mediaType === void 0 ? "" : attr$1("media-type", download.mediaType);
815
+ return `<download${attr$1("path", download.path)}${attr$1("url", download.url)}${media}${attr$1("bytes", String(download.bytes))}${attr$1("sha256", download.sha256)}${attr$1("observed", download.observed)}></download>`;
816
+ }
817
+ /**
818
+ * `omitInPresence`: the page's body is the same one presence carries, so a
819
+ * page in presence gets only its attributes.
820
+ */
821
+ function renderOutcome(pages, screenshot, omitInPresence = false) {
822
+ return pages.map((page) => {
823
+ const main = "representation" in page ? renderPage(page, screenshot, omitInPresence && page.inPresence) : renderPageError(page);
824
+ if (!("representation" in page) || page.download === void 0) return main;
825
+ return `${main}\n${renderDownload(page.download)}`;
826
+ }).join("\n");
827
+ }
828
+ function tabSnap(tabs, closed) {
829
+ return {
830
+ tabs: tabs.map((tab) => ({
831
+ id: tab.tab,
832
+ url: tab.url,
833
+ presence: tab.inPresence,
834
+ changed: tab.mayHaveChanged,
835
+ inUse: tab.occupied
836
+ })),
837
+ closed: [...closed]
838
+ };
839
+ }
840
+ /**
841
+ * Attach the full list when this session has not attached one yet, when the
842
+ * tab key changed, or when this result closed something. Title is not a change.
843
+ */
844
+ function tabsChanged(previous, next) {
845
+ if (next.closed.length > 0) return true;
846
+ if (previous === void 0) return next.tabs.length > 0;
847
+ return JSON.stringify(previous.tabs) !== JSON.stringify(next.tabs);
848
+ }
849
+ /** Reading fields persisted in tool meta. `by` marks the result as this plugin's. */
850
+ function readingsOf(pages, action) {
851
+ return {
852
+ by: "mh-browser",
853
+ action,
854
+ pages: pages.map((page) => {
855
+ if (!("representation" in page)) return {
856
+ url: page.url,
857
+ ...page.tab === void 0 ? {} : { tab: page.tab },
858
+ representation: "snapshot",
859
+ reason: page.reason
860
+ };
861
+ const version = versionOf$1(page);
862
+ const digest = page.headerOnly ? void 0 : digestOf$1(page);
863
+ const id = page.headerOnly ? void 0 : idOf(page);
864
+ return {
865
+ url: page.url,
866
+ ...page.tab === void 0 ? {} : { tab: page.tab },
867
+ representation: page.headerOnly ? "header" : page.representation.kind,
868
+ ...version === void 0 ? {} : { version },
869
+ ...digest === void 0 ? {} : { digest },
870
+ ...id === void 0 ? {} : { id },
871
+ ...page.incomplete === void 0 ? {} : { incomplete: incompleteText(page.incomplete) }
872
+ };
873
+ })
874
+ };
875
+ }
876
+ function pageAttrs(view, screenshot) {
877
+ const representation = view.representation;
878
+ const version = versionOf$1(view);
879
+ const agentView = representation.kind === "snapshot" && representation.agentView !== void 0 ? attr$1("agent-view", "available") : "";
880
+ return [
881
+ attr$1("url", view.url),
882
+ view.tab === void 0 ? "" : attr$1("tab", view.tab),
883
+ view.headerOnly ? attr$1("title", view.title) : "",
884
+ attr$1("representation", representation.kind),
885
+ version === void 0 ? "" : attr$1("version", version),
886
+ attr$1("observed", view.observedAt),
887
+ view.fragment === void 0 ? "" : attr$1("fragment", view.fragment),
888
+ view.status === void 0 ? "" : attr$1("status", String(view.status)),
889
+ view.mediaType === void 0 ? "" : attr$1("media-type", view.mediaType),
890
+ view.incomplete === void 0 ? "" : attr$1("incomplete", incompleteText(view.incomplete)),
891
+ view.waited === void 0 ? "" : attr$1("waited", waitedText(view.waited)),
892
+ view.inPresence ? attr$1("in-presence", "true") : "",
893
+ agentView,
894
+ representation.kind === "snapshot" && representation.agentViewError !== void 0 ? attr$1("agent-view-error", representation.agentViewError) : "",
895
+ screenshot === void 0 ? "" : attr$1("screenshot", screenshot)
896
+ ].join("");
897
+ }
898
+ const BODY_LIMIT = 2e5;
899
+ function pageBody(view) {
900
+ const representation = view.representation;
901
+ if (view.mediaType === void 0 || representation.kind !== "snapshot") return representation.text;
902
+ if (view.body !== void 0) {
903
+ if (view.body.length <= BODY_LIMIT) return view.body;
904
+ return `${view.body.slice(0, BODY_LIMIT)}\nCut at ${String(BODY_LIMIT)} characters; use browser_download to save the whole file into the workspace.`;
905
+ }
906
+ if (view.status !== void 0) return `The site answered HTTP ${String(view.status)}; the ${view.mediaType} body is not shown.`;
907
+ return "Not an HTML page; use browser_download to save it into the workspace.";
908
+ }
909
+ function versionOf$1(view) {
910
+ const representation = view.representation;
911
+ if (representation.kind === "agent-view") return representation.version;
912
+ return representation.agentView?.version;
913
+ }
914
+ function digestOf$1(view) {
915
+ if (view.representation.kind !== "agent-view") return void 0;
916
+ return representationDigest(view.representation.text);
917
+ }
918
+ function idOf(view) {
919
+ if (view.representation.kind !== "agent-view") return void 0;
920
+ return agentViewId(view.representation.text, view.url);
921
+ }
922
+ function attr$1(name, value) {
923
+ return ` ${name}="${escapeAttr(value)}"`;
924
+ }
925
+ function formatSeconds(seconds) {
926
+ const rounded = Math.round(seconds * 10) / 10;
927
+ return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
928
+ }
929
+ //#endregion
662
930
  //#region lib/types/tools/delivery.js
663
931
  /** Every mh-browser tool result's `meta.by`; a result without it is not a delivery. */
664
932
  const META_BY = "mh-browser";
@@ -670,81 +938,70 @@ function initDeliveries() {
670
938
  };
671
939
  }
672
940
  /**
673
- * One event folded into the delivery record. A tool result shadowed by
674
- * `compaction/prune` or replaced on the surface is out of context, and its
675
- * replacement is not a new delivery; a summary puts everything before it out
676
- * of context, presence reminders included: the replacement baseline that
677
- * follows the summary is the delivery that counts.
941
+ * One event folded into the delivery record. Compaction does not revoke a
942
+ * delivery. A later result with the same call id is the replacement a prune
943
+ * appends, so it does not replace the text the model was shown.
678
944
  */
679
945
  function applyDelivery(state, event) {
680
- if (event.type === "compaction/summary") return markOut(state, (item) => item.seq < event.seq);
681
- if (event.type === "compaction/prune") {
682
- const shadowed = new Set(numberList(record$2(event.data).shadowedSeqs));
683
- return markOut(state, (item) => shadowed.has(item.seq));
684
- }
685
- const replaced = replacedRange(event.surfaceOp);
686
- if (replaced !== void 0) return markOut(state, (item) => item.seq >= replaced.start && item.seq <= replaced.end);
946
+ if (event.type === "compaction/summary" || event.type === "compaction/prune") return state;
687
947
  if (event.type === "tool/result") return withShown(state, fromTool(event));
688
948
  if (event.type === "user/message") return withShown(state, fromPresence(event));
689
949
  return state;
690
950
  }
691
951
  /**
692
- * The Agent View of this address the model saw last, and whether it is still
693
- * in context. A page shown only as a snapshot has no Agent View digest.
694
- */
695
- function latestSeen(state, url) {
696
- const matches = state.deliveries.filter((item) => sameUrl(item.url, url));
697
- if (matches.length === 0) return void 0;
698
- const latest = matches.filter((item) => item.digest !== void 0).at(-1);
699
- if (latest === void 0) return { digest: "" };
700
- if (!latest.inContext) return "not-in-context";
701
- return seenOf(latest);
702
- }
703
- function seenOf(item) {
704
- return {
705
- digest: item.digest ?? "",
706
- ...item.version === void 0 ? {} : { version: item.version }
707
- };
708
- }
709
- /**
710
952
  * The document `page` names: the one shown at that address, else the one whose
711
953
  * Agent View declares that `id`. One `id` can name several documents, such as
712
- * the pages of a collection; when more than one is in context and they
713
- * differ, the caller must pick an address.
954
+ * the pages of a collection; when they differ, the caller must pick an address.
955
+ * `text` is the body the model was shown for that document.
714
956
  */
715
957
  function resolvePage(state, page) {
716
- const atAddress = latestSeen(state, page);
717
- if (atAddress !== void 0) return {
718
- url: page,
719
- seen: atAddress
720
- };
958
+ const atAddress = documentOf(state.deliveries.filter((item) => sameUrl(item.url, page)));
959
+ if (atAddress !== void 0) return shownPage(atAddress);
721
960
  const named = state.deliveries.filter((item) => item.id !== void 0 && sameUrl(item.id, page));
722
961
  if (named.length === 0) return void 0;
723
962
  const byUrl = /* @__PURE__ */ new Map();
724
963
  for (const item of named) {
725
- byUrl.delete(item.url);
726
- byUrl.set(item.url, item);
964
+ const list = byUrl.get(item.url) ?? [];
965
+ list.push(item);
966
+ byUrl.set(item.url, list);
727
967
  }
728
- const latest = [...byUrl.values()];
729
- const current = latest.filter((item) => {
730
- const seen = latestSeen(state, item.url);
731
- return seen !== void 0 && seen !== "not-in-context" && seen.digest === item.digest;
968
+ const latest = [...byUrl.values()].flatMap((list) => {
969
+ const doc = documentOf(list);
970
+ return doc === void 0 ? [] : [doc];
732
971
  });
733
- const last = latest.at(-1);
734
- if (current.length === 0) return last === void 0 ? void 0 : {
735
- url: last.url,
736
- seen: "not-in-context"
737
- };
738
- if (new Set(current.map((item) => item.digest)).size > 1) return { ambiguous: current.map((item) => item.url) };
739
- const chosen = current.at(-1);
972
+ if (new Set(latest.map((item) => item.digest)).size > 1) return { ambiguous: latest.map((item) => item.url) };
973
+ const chosen = latest.reduce((best, item) => best === void 0 || item.seq >= best.seq ? item : best, void 0);
740
974
  if (chosen === void 0) return void 0;
975
+ return shownPage(chosen);
976
+ }
977
+ function shownPage(item) {
741
978
  return {
742
- url: chosen.url,
743
- seen: seenOf(chosen)
979
+ url: item.url,
980
+ text: item.text ?? ""
744
981
  };
745
982
  }
983
+ /**
984
+ * The last Agent View shown at these deliveries, else the last delivery. A
985
+ * result that left the body to presence carries the same digest without the
986
+ * body; the body comes from the latest delivery with that digest that has one.
987
+ */
988
+ function documentOf(matches) {
989
+ const views = matches.filter((item) => item.digest !== void 0);
990
+ const last = views.at(-1);
991
+ if (last === void 0) return matches.at(-1);
992
+ if (last.text !== void 0) return last;
993
+ for (let index = views.length - 1; index >= 0; index -= 1) {
994
+ const item = views[index];
995
+ if (item !== void 0 && item.digest === last.digest && item.text !== void 0) return {
996
+ ...last,
997
+ text: item.text
998
+ };
999
+ }
1000
+ return last;
1001
+ }
746
1002
  function withShown(state, shown) {
747
1003
  if (shown === void 0 || shown.deliveries.length === 0) return state;
1004
+ if (shown.callId !== void 0 && state.deliveries.some((item) => item.callId === shown.callId)) return state;
748
1005
  const known = new Set(state.tokens);
749
1006
  const fresh = urlsIn(shown.text).filter((token) => {
750
1007
  if (known.has(token)) return false;
@@ -756,16 +1013,6 @@ function withShown(state, shown) {
756
1013
  tokens: fresh.length === 0 ? state.tokens : [...state.tokens, ...fresh]
757
1014
  };
758
1015
  }
759
- function markOut(state, out) {
760
- if (!state.deliveries.some((item) => item.inContext && out(item))) return state;
761
- return {
762
- ...state,
763
- deliveries: state.deliveries.map((item) => item.inContext && out(item) ? {
764
- ...item,
765
- inContext: false
766
- } : item)
767
- };
768
- }
769
1016
  const URL_TOKEN = /https?:\/\/[^\s"'<>`]+/gi;
770
1017
  function urlsIn(text) {
771
1018
  const found = [];
@@ -783,8 +1030,8 @@ function fromTool(event) {
783
1030
  const data = record$2(event.data);
784
1031
  const message = record$2(data.message);
785
1032
  const callId = readString(message.toolCallId);
786
- const text = textOf$1(message.content);
787
- if (message.isError === true) return attachedPage(event.seq, data, text, callId);
1033
+ const text = textOf$2(message.content);
1034
+ if (message.isError === true) return void 0;
788
1035
  const meta = record$2(data.meta);
789
1036
  if (meta.by !== "mh-browser") return void 0;
790
1037
  const pages = meta.pages;
@@ -792,25 +1039,28 @@ function fromTool(event) {
792
1039
  const deliveries = [];
793
1040
  for (const page of pages) {
794
1041
  const row = record$2(page);
1042
+ if (row.representation === "header" || readString(row.reason) !== void 0) continue;
795
1043
  const url = readString(row.url);
796
1044
  if (url === void 0) continue;
797
1045
  const digest = readString(row.digest);
798
1046
  const version = readString(row.version);
799
1047
  const id = readString(row.id);
1048
+ const body = digest === void 0 ? void 0 : shownBody(text, url);
800
1049
  deliveries.push({
801
1050
  url,
802
1051
  seq: event.seq,
803
1052
  kind: "tool",
804
- inContext: true,
805
1053
  ...callId === void 0 ? {} : { callId },
806
1054
  ...digest === void 0 ? {} : { digest },
807
1055
  ...version === void 0 ? {} : { version },
808
- ...id === void 0 ? {} : { id }
1056
+ ...id === void 0 ? {} : { id },
1057
+ ...body === void 0 ? {} : { text: body }
809
1058
  });
810
1059
  }
811
1060
  return {
812
1061
  deliveries,
813
- text
1062
+ text,
1063
+ ...callId === void 0 ? {} : { callId }
814
1064
  };
815
1065
  }
816
1066
  function fromPresence(event) {
@@ -828,76 +1078,65 @@ function fromPresence(event) {
828
1078
  const digest = readString(row.digest);
829
1079
  const version = readString(row.version);
830
1080
  const id = readString(row.id);
1081
+ const body = digest === void 0 ? void 0 : shownBody(textOf$2(message.content), url);
831
1082
  deliveries.push({
832
1083
  url,
833
1084
  seq: event.seq,
834
1085
  kind: "presence",
835
- inContext: true,
836
1086
  ...digest === void 0 ? {} : { digest },
837
1087
  ...version === void 0 ? {} : { version },
838
- ...id === void 0 ? {} : { id }
1088
+ ...id === void 0 ? {} : { id },
1089
+ ...body === void 0 ? {} : { text: body }
839
1090
  });
840
1091
  }
841
1092
  return {
842
1093
  deliveries,
843
- text: textOf$1(message.content)
1094
+ text: textOf$2(message.content)
844
1095
  };
845
1096
  }
1097
+ const OMITTED_BODY = /* @__PURE__ */ new Set([
1098
+ "",
1099
+ "Content follows in the next presence reminder.",
1100
+ "Open this page to read it or act on it."
1101
+ ]);
846
1102
  /**
847
- * An error result carries no meta. Only `STALE_VIEW` attaches a page that
848
- * counts as delivered, and the service writes that page's header, with its
849
- * Agent View digest, before any site text: so only the first `<page` header
850
- * of this tool's own error counts.
1103
+ * The `<page>` body the model was shown for this address. Site text cannot
1104
+ * open or close a frame (`escapeBody`), so every frame tag here is the
1105
+ * Service's. A spill can still cut out the closer and whatever follows it up
1106
+ * to a later closer; the body ends at the spill gap or at the next frame the
1107
+ * Service opened, whichever comes first.
851
1108
  */
852
- function attachedPage(seq, data, text, callId) {
853
- const error = record$2(data.error);
854
- if (error.name !== "BrowserToolError" || error.code !== "STALE_VIEW") return void 0;
855
- const start = text.indexOf("<page ");
856
- if (start < 0) return void 0;
857
- const end = text.indexOf(">", start);
858
- if (end < 0) return void 0;
859
- const header = text.slice(start, end);
860
- const url = attrOf(header, "url");
861
- const digest = attrOf(header, "digest");
862
- const version = attrOf(header, "version");
863
- if (url === void 0 || digest === void 0) return void 0;
864
- const close = text.indexOf("</page>", end);
865
- const id = agentViewId(text.slice(end + 1, close < 0 ? void 0 : close), url);
866
- return {
867
- deliveries: [{
868
- url,
869
- seq,
870
- kind: "tool",
871
- digest,
872
- ...version === void 0 ? {} : { version },
873
- inContext: true,
874
- ...callId === void 0 ? {} : { callId },
875
- ...id === void 0 ? {} : { id }
876
- }],
877
- text
878
- };
1109
+ function shownBody(text, url) {
1110
+ for (const match of text.matchAll(/<(page|added|changed)(?=[\s>])([^>]*)>([\s\S]*?)<\/\1>/g)) {
1111
+ const found = attrOf(match[2] ?? "", "url");
1112
+ if (found === void 0 || !sameUrl(found, url)) continue;
1113
+ const body = unescapeBody(upToBoundary(withoutDialog(match[3] ?? "")));
1114
+ if (OMITTED_BODY.has(body.trim())) return void 0;
1115
+ return body;
1116
+ }
1117
+ }
1118
+ const SPILL_GAP = "\n\n[...]\n\n";
1119
+ const FRAME_OPENER = new RegExp(`<(${FRAMES.join("|")})(?=[\\s>/])`);
1120
+ function upToBoundary(body) {
1121
+ const gap = body.indexOf(SPILL_GAP);
1122
+ const kept = gap === -1 ? body : body.slice(0, gap);
1123
+ const opener = FRAME_OPENER.exec(kept);
1124
+ return opener === null ? kept : kept.slice(0, opener.index);
1125
+ }
1126
+ const DIALOG_SUFFIX = /\n<dialog(?=[\s>])[^>]*>[\s\S]*?<\/dialog>$/;
1127
+ function withoutDialog(body) {
1128
+ return body.replace(DIALOG_SUFFIX, "");
1129
+ }
1130
+ const ESCAPED_TAG = new RegExp(`<\\\\(/?)(${FRAMES.join("|")})(?![\\w-])`, "g");
1131
+ function unescapeBody(body) {
1132
+ return body.replace(ESCAPED_TAG, "<$1$2");
879
1133
  }
880
1134
  function attrOf(header, name) {
881
1135
  const value = new RegExp(`\\s${name}="([^"]*)"`).exec(header)?.[1];
882
1136
  if (value === void 0 || value === "") return void 0;
883
1137
  return value.replace(/&quot;/g, "\"").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#10;/g, "\n").replace(/&#13;/g, "\r").replace(/&#9;/g, " ").replace(/&amp;/g, "&");
884
1138
  }
885
- function replacedRange(op) {
886
- const row = record$2(op);
887
- if (row.op !== "replace") return void 0;
888
- const start = row.startSeq;
889
- const end = row.endSeq;
890
- if (typeof start !== "number" || typeof end !== "number" || end < start) return void 0;
891
- return {
892
- start,
893
- end
894
- };
895
- }
896
- function numberList(value) {
897
- if (!Array.isArray(value)) return [];
898
- return value.filter((item) => typeof item === "number");
899
- }
900
- function textOf$1(content) {
1139
+ function textOf$2(content) {
901
1140
  if (!Array.isArray(content)) return "";
902
1141
  const parts = [];
903
1142
  for (const block of content) {
@@ -931,16 +1170,38 @@ function initTabLog() {
931
1170
  return { open: [] };
932
1171
  }
933
1172
  function applyTabLog(state, event) {
934
- if (event.type !== "tool/result") return state;
1173
+ const cleared = event.type === "compaction/summary" || event.type === "compaction/prune" || event.surfaceOp === "replace";
1174
+ if (event.type !== "tool/result") return cleared ? withoutSnap(state) : state;
935
1175
  const meta = record$1(record$1(event.data).meta);
936
- if (meta.by !== "mh-browser") return state;
1176
+ if (meta.by !== "mh-browser") return cleared ? withoutSnap(state) : state;
937
1177
  const named = Array.isArray(meta.pages) ? meta.pages.map((page) => record$1(page).tab).filter(isTabId) : [];
938
1178
  const closed = Array.isArray(meta.closed) ? meta.closed.filter((note) => typeof note === "string").map(tabOfNote) : [];
939
- if (named.length === 0 && closed.length === 0) return state;
940
1179
  const next = new Set(state.open);
941
1180
  for (const tab of named) next.add(tab);
942
1181
  for (const tab of closed) next.delete(tab);
943
- return { open: [...next] };
1182
+ const keptSnap = (cleared ? void 0 : snapOf(meta.tabs)) ?? (cleared ? void 0 : state.snap);
1183
+ const lastTab = Array.isArray(meta.pages) ? named[named.length - 1] : state.lastTab;
1184
+ return {
1185
+ open: [...next],
1186
+ ...lastTab === void 0 ? {} : { lastTab },
1187
+ ...keptSnap === void 0 ? {} : { snap: keptSnap }
1188
+ };
1189
+ }
1190
+ /** The `<tabs>` suffix for this result, and the snap to store when it is attached. */
1191
+ function tabBlock(ctx, reader, session) {
1192
+ const closed = closedNotes(ctx, reader, session);
1193
+ const live = ctx.mhBrowser.tabs(session.id);
1194
+ const snap = tabSnap(live, closed);
1195
+ const previous = reader.tabs(session.session).snap;
1196
+ if (!tabsChanged(previous, snap)) return {
1197
+ suffix: "",
1198
+ closed
1199
+ };
1200
+ return {
1201
+ suffix: `\n${renderTabs(live, closed)}`,
1202
+ closed,
1203
+ snap
1204
+ };
944
1205
  }
945
1206
  /**
946
1207
  * The closed notes for this result: the service's own, then each tab an
@@ -959,6 +1220,28 @@ function tabOfNote(note) {
959
1220
  function isTabId(value) {
960
1221
  return typeof value === "string" && /^t\d+$/.test(value);
961
1222
  }
1223
+ function withoutSnap(state) {
1224
+ const { snap: _snap, ...rest } = state;
1225
+ return rest;
1226
+ }
1227
+ function snapOf(value) {
1228
+ const snap = record$1(value);
1229
+ if (!Array.isArray(snap.tabs)) return void 0;
1230
+ return {
1231
+ tabs: snap.tabs.flatMap((item) => {
1232
+ const row = record$1(item);
1233
+ if (typeof row.id !== "string" || typeof row.url !== "string") return [];
1234
+ return [{
1235
+ id: row.id,
1236
+ url: row.url,
1237
+ presence: row.presence === true,
1238
+ changed: row.changed === true,
1239
+ inUse: row.inUse === true
1240
+ }];
1241
+ }),
1242
+ closed: Array.isArray(snap.closed) ? snap.closed.filter((note) => typeof note === "string") : []
1243
+ };
1244
+ }
962
1245
  function record$1(value) {
963
1246
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
964
1247
  }
@@ -968,7 +1251,7 @@ function record$1(value) {
968
1251
  function registerProjections(ctx) {
969
1252
  ctx.sessionProjections.register({
970
1253
  key: "mhBrowserDeliveries",
971
- stateVersion: 1,
1254
+ stateVersion: 2,
972
1255
  stateSchema: plainObject(["deliveries", "tokens"]),
973
1256
  init: initDeliveries,
974
1257
  apply: (state, event) => applyDelivery(state, {
@@ -1000,7 +1283,7 @@ function registerProjections(ctx) {
1000
1283
  });
1001
1284
  ctx.sessionProjections.register({
1002
1285
  key: "mhBrowserTabs",
1003
- stateVersion: 1,
1286
+ stateVersion: 2,
1004
1287
  stateSchema: plainObject(["open"]),
1005
1288
  init: initTabLog,
1006
1289
  apply: (state, event) => applyTabLog(state, event)
@@ -1024,23 +1307,676 @@ function plainObject(keys) {
1024
1307
  return z$1.custom((value) => value !== null && typeof value === "object" && keys.every((key) => key in value));
1025
1308
  }
1026
1309
  //#endregion
1027
- //#region lib/types/presence/notice.js
1028
- /** The sentence a session sees after the person hands the browser back. */
1029
- function takeoverText(taken) {
1030
- const tabs = taken.tabs.length === 0 ? "none" : taken.tabs.join(", ");
1031
- return [
1032
- "<system-reminder>",
1033
- `The person took over the browser and handed it back (${String(taken.operations)} operations, ${taken.from}–${taken.to}). Tabs that may have changed: ${tabs}. Look at them again before acting on them.`,
1034
- "</system-reminder>"
1035
- ].join("\n");
1036
- }
1037
- //#endregion
1038
- //#region lib/types/presence/register.js
1039
- const BROWSER_TOOLS = /* @__PURE__ */ new Set([
1040
- "browser_open",
1041
- "browser_act",
1310
+ //#region lib/types/tools/page-ops.js
1311
+ const IMAGE_VALUE = {
1312
+ type: "object",
1313
+ additionalProperties: false,
1314
+ properties: {
1315
+ attachmentId: {
1316
+ type: "string",
1317
+ required: true
1318
+ },
1319
+ mediaType: {
1320
+ type: "string",
1321
+ enum: [
1322
+ "image/png",
1323
+ "image/jpeg",
1324
+ "image/webp",
1325
+ "image/gif"
1326
+ ],
1327
+ required: true
1328
+ },
1329
+ bytes: {
1330
+ type: "integer",
1331
+ required: true
1332
+ },
1333
+ width: {
1334
+ type: "integer",
1335
+ required: true
1336
+ },
1337
+ height: {
1338
+ type: "integer",
1339
+ required: true
1340
+ },
1341
+ name: { type: "string" },
1342
+ originalDimensions: {
1343
+ type: "object",
1344
+ additionalProperties: false,
1345
+ properties: {
1346
+ width: {
1347
+ type: "integer",
1348
+ required: true
1349
+ },
1350
+ height: {
1351
+ type: "integer",
1352
+ required: true
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ };
1358
+ const TAB$1 = "Tab id copied from the latest result in this session: a tabs list, or a page element's tab attribute. Omit it to use the tab of that latest result; if it has no tab, the call fails with INVALID_ARGS. An idle tab may be closed, and a closed id fails with UNKNOWN_TAB: open the address again with browser_open. A tab with unsubmitted input is marked in use and is not closed, and opening or a declared action does not reload it. Tabs are gone after a restart. A page with an Agent View has no tab until you open it with snapshot.";
1359
+ const TARGET = "A ref copied from this tab's latest snapshot, such as e3, or a selector that matches one element. A ref that is not in the current snapshot fails with STALE_REF.";
1360
+ const ELEMENT = "What the person sees in the log. It does not locate the element.";
1361
+ const FILENAME = "A path inside the workspace. The result is saved there and the reply is the path and the byte count. Omit it and the result stays in the reply.";
1362
+ const OUTPUT = {
1363
+ schema: {
1364
+ type: "object",
1365
+ additionalProperties: false,
1366
+ properties: {
1367
+ text: {
1368
+ type: "string",
1369
+ required: true
1370
+ },
1371
+ meta: {
1372
+ type: "json",
1373
+ required: true
1374
+ },
1375
+ image: IMAGE_VALUE
1376
+ }
1377
+ },
1378
+ render: (_args, value) => {
1379
+ const text = {
1380
+ type: "text",
1381
+ text: value.text
1382
+ };
1383
+ if (value.image === void 0) return [text];
1384
+ return [text, {
1385
+ type: "image",
1386
+ attachment: imageRef(value.image)
1387
+ }];
1388
+ },
1389
+ presentationMeta: (_args, value) => value.meta
1390
+ };
1391
+ const TARGET_FIELDS = {
1392
+ target: {
1393
+ type: "string",
1394
+ required: true,
1395
+ description: TARGET
1396
+ },
1397
+ element: {
1398
+ type: "string",
1399
+ description: ELEMENT
1400
+ }
1401
+ };
1402
+ const PAGE_OPERATION_NAMES = [
1403
+ "browser_click",
1404
+ "browser_hover",
1405
+ "browser_select_option",
1406
+ "browser_drag",
1407
+ "browser_type",
1408
+ "browser_fill_form",
1409
+ "browser_press_key",
1410
+ "browser_file_upload",
1411
+ "browser_handle_dialog",
1412
+ "browser_navigate_back",
1413
+ "browser_wait_for",
1414
+ "browser_snapshot",
1415
+ "browser_find",
1416
+ "browser_take_screenshot",
1417
+ "browser_evaluate"
1418
+ ];
1419
+ const OPS = [
1420
+ {
1421
+ name: "browser_click",
1422
+ description: "Click an element.",
1423
+ properties: {
1424
+ ...TARGET_FIELDS,
1425
+ doubleClick: {
1426
+ type: "boolean",
1427
+ description: "Double-click instead of click."
1428
+ },
1429
+ button: {
1430
+ type: "string",
1431
+ enum: [
1432
+ "left",
1433
+ "right",
1434
+ "middle"
1435
+ ],
1436
+ description: "Mouse button. Defaults to left."
1437
+ },
1438
+ modifiers: {
1439
+ type: "array",
1440
+ description: "Modifier keys held during the click.",
1441
+ items: {
1442
+ type: "string",
1443
+ enum: [
1444
+ "Alt",
1445
+ "Control",
1446
+ "ControlOrMeta",
1447
+ "Meta",
1448
+ "Shift"
1449
+ ]
1450
+ }
1451
+ }
1452
+ },
1453
+ build: async (args) => ({
1454
+ kind: "click",
1455
+ target: string(args.target),
1456
+ ...optionalString(args, "element"),
1457
+ ...args.doubleClick === true ? { doubleClick: true } : {},
1458
+ ...args.button === "left" || args.button === "right" || args.button === "middle" ? { button: args.button } : {},
1459
+ ...Array.isArray(args.modifiers) ? { modifiers: args.modifiers.filter(isModifier) } : {}
1460
+ })
1461
+ },
1462
+ {
1463
+ name: "browser_hover",
1464
+ description: "Hover over an element.",
1465
+ properties: { ...TARGET_FIELDS },
1466
+ build: async (args) => ({
1467
+ kind: "hover",
1468
+ target: string(args.target),
1469
+ ...optionalString(args, "element")
1470
+ })
1471
+ },
1472
+ {
1473
+ name: "browser_select_option",
1474
+ description: "Choose options on a list element. This does not submit.",
1475
+ properties: {
1476
+ ...TARGET_FIELDS,
1477
+ values: {
1478
+ type: "array",
1479
+ required: true,
1480
+ description: "Option values copied from the control. At least one.",
1481
+ items: { type: "string" }
1482
+ }
1483
+ },
1484
+ build: async (args) => ({
1485
+ kind: "select_option",
1486
+ target: string(args.target),
1487
+ values: stringList$1(args.values),
1488
+ ...optionalString(args, "element")
1489
+ })
1490
+ },
1491
+ {
1492
+ name: "browser_drag",
1493
+ description: "Drag from one element to another.",
1494
+ properties: {
1495
+ startTarget: {
1496
+ type: "string",
1497
+ required: true,
1498
+ description: TARGET
1499
+ },
1500
+ startElement: {
1501
+ type: "string",
1502
+ description: ELEMENT
1503
+ },
1504
+ endTarget: {
1505
+ type: "string",
1506
+ required: true,
1507
+ description: TARGET
1508
+ },
1509
+ endElement: {
1510
+ type: "string",
1511
+ description: ELEMENT
1512
+ }
1513
+ },
1514
+ build: async (args) => ({
1515
+ kind: "drag",
1516
+ startTarget: string(args.startTarget),
1517
+ endTarget: string(args.endTarget),
1518
+ ...optional("startElement", args.startElement),
1519
+ ...optional("endElement", args.endElement)
1520
+ })
1521
+ },
1522
+ {
1523
+ name: "browser_type",
1524
+ description: "Type into an element. submit presses Enter afterwards; otherwise this does not submit. Do not type into a login form or a form that issues credentials; call browser_handover.",
1525
+ properties: {
1526
+ ...TARGET_FIELDS,
1527
+ text: {
1528
+ type: "string",
1529
+ required: true,
1530
+ description: "Text to type into the element."
1531
+ },
1532
+ submit: {
1533
+ type: "boolean",
1534
+ description: "Press Enter after typing."
1535
+ },
1536
+ slowly: {
1537
+ type: "boolean",
1538
+ description: "Type one character at a time. Otherwise the whole text is filled at once."
1539
+ }
1540
+ },
1541
+ build: async (args) => ({
1542
+ kind: "type",
1543
+ target: string(args.target),
1544
+ text: string(args.text),
1545
+ ...optionalString(args, "element"),
1546
+ ...args.submit === true ? { submit: true } : {},
1547
+ ...args.slowly === true ? { slowly: true } : {}
1548
+ })
1549
+ },
1550
+ {
1551
+ name: "browser_fill_form",
1552
+ description: "Fill fields. This does not submit. Do not fill a login form or a form that issues credentials; call browser_handover.",
1553
+ properties: { fields: {
1554
+ type: "array",
1555
+ required: true,
1556
+ description: "Fields to write in one step. At least one. This does not submit.",
1557
+ items: {
1558
+ type: "object",
1559
+ additionalProperties: false,
1560
+ properties: {
1561
+ ...TARGET_FIELDS,
1562
+ name: {
1563
+ type: "string",
1564
+ required: true,
1565
+ description: "Field name, for the person reading the log."
1566
+ },
1567
+ type: {
1568
+ type: "string",
1569
+ required: true,
1570
+ enum: [
1571
+ "textbox",
1572
+ "checkbox",
1573
+ "radio",
1574
+ "combobox",
1575
+ "slider"
1576
+ ],
1577
+ description: "textbox, checkbox, radio, combobox, or slider."
1578
+ },
1579
+ value: {
1580
+ type: "string",
1581
+ required: true,
1582
+ description: "Text to write. A checkbox or radio is 'true' or 'false'. A combobox is the option text."
1583
+ }
1584
+ }
1585
+ }
1586
+ } },
1587
+ build: async (args) => ({
1588
+ kind: "fill_form",
1589
+ fields: fieldsOf(args.fields)
1590
+ })
1591
+ },
1592
+ {
1593
+ name: "browser_press_key",
1594
+ description: "Press a key on this tab. Enter returns a snapshot; any other key returns a sentence.",
1595
+ properties: { key: {
1596
+ type: "string",
1597
+ required: true,
1598
+ description: "Key name, such as Enter or ArrowDown, or one character."
1599
+ } },
1600
+ build: async (args) => ({
1601
+ kind: "press_key",
1602
+ key: string(args.key)
1603
+ })
1604
+ },
1605
+ {
1606
+ name: "browser_file_upload",
1607
+ description: "Give files to the open file chooser. Omit paths to cancel it.",
1608
+ properties: { paths: {
1609
+ type: "array",
1610
+ description: "Attachment references from this session, or paths inside the workspace. Omit it to cancel the chooser.",
1611
+ items: { type: "string" }
1612
+ } },
1613
+ build: async (args, ctx, reader, session) => {
1614
+ const values = Array.isArray(args.paths) ? args.paths : void 0;
1615
+ const paths = values === void 0 ? void 0 : await uploadPaths(ctx, reader.attachments(session.session), session.cwd, values);
1616
+ return {
1617
+ kind: "file_upload",
1618
+ ...paths === void 0 ? {} : { paths }
1619
+ };
1620
+ }
1621
+ },
1622
+ {
1623
+ name: "browser_handle_dialog",
1624
+ description: "Accept or dismiss the dialog open on this tab.",
1625
+ properties: {
1626
+ accept: {
1627
+ type: "boolean",
1628
+ required: true,
1629
+ description: "True accepts. False dismisses."
1630
+ },
1631
+ promptText: {
1632
+ type: "string",
1633
+ description: "Text to enter when accepting a prompt."
1634
+ }
1635
+ },
1636
+ build: async (args) => ({
1637
+ kind: "handle_dialog",
1638
+ accept: args.accept === true,
1639
+ ...typeof args.promptText === "string" ? { promptText: args.promptText } : {}
1640
+ })
1641
+ },
1642
+ {
1643
+ name: "browser_navigate_back",
1644
+ description: "Go back one step in this tab's history.",
1645
+ properties: {},
1646
+ build: async () => ({ kind: "navigate_back" })
1647
+ },
1648
+ {
1649
+ name: "browser_wait_for",
1650
+ description: "Wait for text to appear or disappear, or for a number of seconds, then return the snapshot.",
1651
+ properties: {
1652
+ text: {
1653
+ type: "string",
1654
+ description: "Text to wait for. The snapshot is returned even if it never appears."
1655
+ },
1656
+ textGone: {
1657
+ type: "string",
1658
+ description: "Text to wait until it disappears. With text set too, the first one wins."
1659
+ },
1660
+ time: {
1661
+ type: "number",
1662
+ description: "Seconds to wait, or the limit for text and textGone. Capped at 30. Default 30."
1663
+ }
1664
+ },
1665
+ build: async (args) => ({
1666
+ kind: "wait_for",
1667
+ ...typeof args.text === "string" ? { text: args.text } : {},
1668
+ ...typeof args.textGone === "string" ? { textGone: args.textGone } : {},
1669
+ ...typeof args.time === "number" ? { time: args.time } : {}
1670
+ })
1671
+ },
1672
+ {
1673
+ name: "browser_snapshot",
1674
+ description: "Return this tab's aria snapshot. A page that also has an Agent View still returns the snapshot; the agent-view attribute says one is available, and its text is not included.",
1675
+ properties: {
1676
+ target: {
1677
+ type: "string",
1678
+ description: "Limit the snapshot to this ref or selector."
1679
+ },
1680
+ depth: {
1681
+ type: "number",
1682
+ description: "Limit how deep the snapshot tree goes."
1683
+ },
1684
+ boxes: {
1685
+ type: "boolean",
1686
+ description: "Include each element's box."
1687
+ },
1688
+ filename: {
1689
+ type: "string",
1690
+ description: FILENAME
1691
+ }
1692
+ },
1693
+ filename: true,
1694
+ build: async (args) => ({
1695
+ kind: "snapshot",
1696
+ ...typeof args.target === "string" ? { target: args.target } : {},
1697
+ ...typeof args.depth === "number" ? { depth: args.depth } : {},
1698
+ ...args.boxes === true ? { boxes: true } : {}
1699
+ })
1700
+ },
1701
+ {
1702
+ name: "browser_find",
1703
+ description: "Search this tab's snapshot and return the matching lines with a little context.",
1704
+ properties: {
1705
+ text: {
1706
+ type: "string",
1707
+ description: "Plain text to find. Case-insensitive. Give text or regex, not both."
1708
+ },
1709
+ regex: {
1710
+ type: "string",
1711
+ description: "Regular expression to find. Wrap it in slashes to add flags, such as /error/i."
1712
+ },
1713
+ maxResults: {
1714
+ type: "number",
1715
+ description: "Maximum number of matches to return. A positive integer."
1716
+ },
1717
+ filename: {
1718
+ type: "string",
1719
+ description: FILENAME
1720
+ }
1721
+ },
1722
+ filename: true,
1723
+ build: async (args) => ({
1724
+ kind: "find",
1725
+ ...typeof args.text === "string" ? { text: args.text } : {},
1726
+ ...typeof args.regex === "string" ? { regex: args.regex } : {},
1727
+ ...typeof args.maxResults === "number" ? { maxResults: args.maxResults } : {}
1728
+ })
1729
+ },
1730
+ {
1731
+ name: "browser_take_screenshot",
1732
+ description: "Take a screenshot of this tab. Use browser_snapshot to read the page and get refs.",
1733
+ properties: {
1734
+ element: {
1735
+ type: "string",
1736
+ description: ELEMENT
1737
+ },
1738
+ target: {
1739
+ type: "string",
1740
+ description: "Screenshot this ref or selector instead of the viewport."
1741
+ },
1742
+ type: {
1743
+ type: "string",
1744
+ enum: [
1745
+ "png",
1746
+ "jpeg",
1747
+ "webp"
1748
+ ],
1749
+ description: "Image format. Defaults to png."
1750
+ },
1751
+ fullPage: {
1752
+ type: "boolean",
1753
+ description: "Capture the full scrollable page. Not with a target."
1754
+ },
1755
+ scale: {
1756
+ type: "string",
1757
+ enum: ["css", "device"],
1758
+ description: "css sizes the image in CSS pixels. device uses device pixels. Defaults to css."
1759
+ },
1760
+ filename: {
1761
+ type: "string",
1762
+ description: FILENAME
1763
+ }
1764
+ },
1765
+ filename: true,
1766
+ image: true,
1767
+ build: async (args) => ({
1768
+ kind: "take_screenshot",
1769
+ ...typeof args.element === "string" ? { element: args.element } : {},
1770
+ ...typeof args.target === "string" ? { target: args.target } : {},
1771
+ ...args.type === "png" || args.type === "jpeg" || args.type === "webp" ? { type: args.type } : {},
1772
+ ...args.fullPage === true ? { fullPage: true } : {},
1773
+ ...args.scale === "css" || args.scale === "device" ? { scale: args.scale } : {}
1774
+ })
1775
+ },
1776
+ {
1777
+ name: "browser_evaluate",
1778
+ description: "Run a function in this tab's page, with the page's login. Write requests made while it runs are listed after the return value.",
1779
+ properties: {
1780
+ element: {
1781
+ type: "string",
1782
+ description: ELEMENT
1783
+ },
1784
+ target: {
1785
+ type: "string",
1786
+ description: "Pass this ref or selector as the function's element argument."
1787
+ },
1788
+ function: {
1789
+ type: "string",
1790
+ required: true,
1791
+ description: "A function, () => { ... } or (element) => { ... } when target is set."
1792
+ },
1793
+ filename: {
1794
+ type: "string",
1795
+ description: FILENAME
1796
+ }
1797
+ },
1798
+ filename: true,
1799
+ build: async (args, _ctx, _reader, _session, exec) => ({
1800
+ kind: "evaluate",
1801
+ function: string(args.function),
1802
+ callId: callIdOf(exec),
1803
+ ...typeof args.element === "string" ? { element: args.element } : {},
1804
+ ...typeof args.target === "string" ? { target: args.target } : {}
1805
+ })
1806
+ }
1807
+ ];
1808
+ function pageOperationTools(ctx) {
1809
+ const reader = logReader(ctx);
1810
+ return OPS.map((spec) => defineOperation(ctx, reader, spec));
1811
+ }
1812
+ function defineOperation(ctx, reader, spec) {
1813
+ return defineTool({
1814
+ name: spec.name,
1815
+ description: spec.description,
1816
+ parameters: {
1817
+ tab: {
1818
+ type: "string",
1819
+ description: TAB$1
1820
+ },
1821
+ ...spec.properties
1822
+ },
1823
+ output: OUTPUT,
1824
+ isConcurrencySafe: () => false,
1825
+ timeoutMs: 6e4,
1826
+ presentCall: (args) => ({
1827
+ card: "generic",
1828
+ kind: "other",
1829
+ title: `${spec.name} ${typeof args.tab === "string" ? args.tab : ""}`.trim()
1830
+ }),
1831
+ presentResult: (args) => ({
1832
+ card: "generic",
1833
+ title: `${spec.name} ${typeof args.tab === "string" ? args.tab : ""}`.trim()
1834
+ }),
1835
+ async execute(args, exec) {
1836
+ const session = sessionFor(ctx, exec);
1837
+ const target = writeTarget(ctx, session);
1838
+ ctx.mhBrowser.noteWorkspace(session.id, target);
1839
+ const input = args;
1840
+ const filename = spec.filename === true && typeof input.filename === "string" ? input.filename : void 0;
1841
+ if (filename !== void 0) await assertInside(target, filename);
1842
+ try {
1843
+ const tab = tabOf(reader, session, args.tab);
1844
+ const op = await spec.build(input, ctx, reader, session, exec);
1845
+ const view = await ctx.mhBrowser.act(session.id, tab, op, exec.signal);
1846
+ const saved = filename === void 0 ? void 0 : await saveResult(target, filename, view);
1847
+ if (saved !== void 0) {
1848
+ view.summary = `${saved.path}\n${String(saved.bytes)} bytes`;
1849
+ if (view.writes === void 0) delete view.detail;
1850
+ delete view.shot;
1851
+ }
1852
+ const image = spec.image === true && filename === void 0 && view.shot !== void 0 ? await saveShot(ctx, view) : void 0;
1853
+ const block = tabBlock(ctx, reader, session);
1854
+ return {
1855
+ text: `${renderOutcome([view], image?.attachmentId)}${block.suffix}`,
1856
+ meta: JSON.parse(JSON.stringify({
1857
+ ...readingsOf([view], spec.name.slice(8)),
1858
+ ...view.writes === void 0 ? {} : { writes: view.writes },
1859
+ closed: block.closed,
1860
+ ...block.snap === void 0 ? {} : { tabs: block.snap }
1861
+ })),
1862
+ ...image === void 0 ? {} : { image: imageValue(image) }
1863
+ };
1864
+ } catch (error) {
1865
+ if (error instanceof MhBrowserError && error.code === "UNKNOWN_TAB") {
1866
+ const block = tabBlock(ctx, reader, session);
1867
+ rethrow(new MhBrowserError(UNKNOWN_TAB, `${UNKNOWN_TAB_REASON} ${typeof args.tab === "string" ? args.tab : ""}`.trim()), `${renderOutcome([])}${block.suffix}`);
1868
+ }
1869
+ if (error instanceof MhBrowserError && (error.code === "STALE_REF" || error.code === "TAKEN_OVER" || error.code === "MODAL_STATE" || error.code === "PERSON_IN_CONTROL") && error.page !== void 0) {
1870
+ const block = tabBlock(ctx, reader, session);
1871
+ rethrow(error, `${renderOutcome([error.page])}${block.suffix}`);
1872
+ }
1873
+ rethrow(error);
1874
+ }
1875
+ }
1876
+ });
1877
+ }
1878
+ /** Probe the path before the action, then write the result the model would have been shown. */
1879
+ async function saveResult(target, filename, view) {
1880
+ return writeInside(target, filename, view.shot?.bytes ?? textOf$1(view));
1881
+ }
1882
+ function textOf$1(view) {
1883
+ if (view.summary !== void 0 && view.summary !== "") return view.detail === void 0 || view.detail === "" ? view.summary : `${view.summary}\n${view.detail}`;
1884
+ return view.representation.text;
1885
+ }
1886
+ function tabOf(reader, session, tab) {
1887
+ if (typeof tab === "string" && tab !== "") return tab;
1888
+ const last = reader.tabs(session.session).lastTab;
1889
+ if (last === void 0) throw new BrowserToolError("INVALID_ARGS", "tab: give a tab, or open the page with snapshot first");
1890
+ return last;
1891
+ }
1892
+ function string(value) {
1893
+ return typeof value === "string" ? value : "";
1894
+ }
1895
+ function stringList$1(value) {
1896
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
1897
+ }
1898
+ function optionalString(args, key) {
1899
+ return typeof args[key] === "string" ? { element: args[key] } : {};
1900
+ }
1901
+ function optional(key, value) {
1902
+ return typeof value === "string" ? { [key]: value } : {};
1903
+ }
1904
+ function fieldsOf(value) {
1905
+ if (!Array.isArray(value)) return [];
1906
+ return value.flatMap((item) => {
1907
+ if (item === null || typeof item !== "object") return [];
1908
+ const row = item;
1909
+ const type = row.type;
1910
+ if (type !== "textbox" && type !== "checkbox" && type !== "radio" && type !== "combobox" && type !== "slider") return [];
1911
+ return [{
1912
+ target: string(row.target),
1913
+ ...typeof row.element === "string" ? { element: row.element } : {},
1914
+ name: string(row.name),
1915
+ type,
1916
+ value: string(row.value)
1917
+ }];
1918
+ });
1919
+ }
1920
+ function callIdOf(exec) {
1921
+ return String(exec.callId);
1922
+ }
1923
+ function imageValue(ref) {
1924
+ return {
1925
+ attachmentId: ref.attachmentId,
1926
+ mediaType: ref.mediaType,
1927
+ bytes: ref.bytes,
1928
+ width: ref.width,
1929
+ height: ref.height,
1930
+ ...ref.name === void 0 ? {} : { name: ref.name },
1931
+ ...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
1932
+ };
1933
+ }
1934
+ function imageRef(value) {
1935
+ return {
1936
+ attachmentId: value.attachmentId,
1937
+ mediaType: value.mediaType,
1938
+ bytes: value.bytes,
1939
+ width: value.width,
1940
+ height: value.height,
1941
+ ...value.name === void 0 ? {} : { name: value.name },
1942
+ ...value.originalDimensions === void 0 ? {} : { originalDimensions: { ...value.originalDimensions } }
1943
+ };
1944
+ }
1945
+ async function saveShot(ctx, view) {
1946
+ const shot = view.shot;
1947
+ if (shot === void 0) return void 0;
1948
+ try {
1949
+ return await ctx.attachments.saveImage({
1950
+ data: shot.bytes,
1951
+ mediaType: shot.mediaType,
1952
+ name: `${view.tab ?? "page"}.${shot.mediaType.slice(6)}`
1953
+ });
1954
+ } catch (error) {
1955
+ ctx.logger.warn("saving the screenshot failed: %o", error);
1956
+ return;
1957
+ }
1958
+ }
1959
+ function isModifier(value) {
1960
+ return value === "Alt" || value === "Control" || value === "ControlOrMeta" || value === "Meta" || value === "Shift";
1961
+ }
1962
+ //#endregion
1963
+ //#region lib/types/presence/notice.js
1964
+ /** The sentence a session sees after the person hands the browser back. */
1965
+ function takeoverText(taken) {
1966
+ const tabs = taken.tabs.length === 0 ? "none" : taken.tabs.join(", ");
1967
+ return [
1968
+ "<system-reminder>",
1969
+ `The person took over the browser and handed it back (${String(taken.operations)} operations, ${taken.from}–${taken.to}). Tabs that may have changed: ${tabs}. Look at them again before acting on them.`,
1970
+ "</system-reminder>"
1971
+ ].join("\n");
1972
+ }
1973
+ //#endregion
1974
+ //#region lib/types/presence/register.js
1975
+ const BROWSER_TOOLS = /* @__PURE__ */ new Set([
1976
+ "browser_open",
1042
1977
  "browser_invoke",
1043
- "browser_presence"
1978
+ "browser_presence",
1979
+ ...PAGE_OPERATION_NAMES
1044
1980
  ]);
1045
1981
  /**
1046
1982
  * Track browser touches, check the pages they named after the step, and merge
@@ -1266,13 +2202,7 @@ function touchedUrls(service, exec, result) {
1266
2202
  const args = record(exec.arguments);
1267
2203
  return [...stringList(args.put), ...stringList(args.remove)].map(canonical$1);
1268
2204
  }
1269
- if (result.isError) {
1270
- if (exec.name !== "browser_invoke" || errorCode(result) !== "STALE_VIEW") return [];
1271
- const page = record(exec.arguments).page;
1272
- if (typeof page !== "string") return [];
1273
- const key = canonical$1(page);
1274
- return known.has(key) ? [key] : [];
1275
- }
2205
+ if (result.isError) return [];
1276
2206
  const value = result.value;
1277
2207
  const pages = record(record(value).meta).pages;
1278
2208
  if (!Array.isArray(pages)) return [];
@@ -1283,11 +2213,6 @@ function touchedUrls(service, exec, result) {
1283
2213
  return known.has(key) ? [key] : [];
1284
2214
  });
1285
2215
  }
1286
- function errorCode(result) {
1287
- if (!result.isError) return void 0;
1288
- const code = record(record(record(result).error).info).code;
1289
- return typeof code === "string" ? code : void 0;
1290
- }
1291
2216
  function stringList(value) {
1292
2217
  if (!Array.isArray(value)) return [];
1293
2218
  return value.filter((item) => typeof item === "string" && item !== "");
@@ -1439,183 +2364,6 @@ var PresenceBook = class {
1439
2364
  }
1440
2365
  };
1441
2366
  //#endregion
1442
- //#region lib/types/render/envelope.js
1443
- const CLOSERS = new RegExp(`<\\s*/\\s*(${[
1444
- "system-reminder",
1445
- "page",
1446
- "page-error",
1447
- "added",
1448
- "changed",
1449
- "removed",
1450
- "absent",
1451
- "action-response",
1452
- "tabs",
1453
- "dialog",
1454
- "download",
1455
- "download-error"
1456
- ].join("|")})(?![\\w-])`, "gi");
1457
- /**
1458
- * Escape a closer a site could use to end a frame the service opened. Only
1459
- * its opening `</name` is rewritten, so a closer cut off before `>` is caught
1460
- * and no site text is dropped.
1461
- */
1462
- function escapeBody(text) {
1463
- return text.replace(CLOSERS, (_match, tag) => `<\\/${tag.toLowerCase()}`);
1464
- }
1465
- /** Escape text the service writes into an attribute. */
1466
- function escapeAttr(text) {
1467
- return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r/g, "&#13;").replace(/\n/g, "&#10;").replace(/\t/g, "&#9;");
1468
- }
1469
- /** Site text that must stay on one line, such as a title in `<tabs>`. */
1470
- function oneLine(text) {
1471
- return text.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ");
1472
- }
1473
- function incompleteText(incomplete) {
1474
- const pending = `${String(incomplete.pendingRequests)} requests pending`;
1475
- return incomplete.load ? pending : `load not fired; ${pending}`;
1476
- }
1477
- function waitedText(waited) {
1478
- const seconds = formatSeconds(waited.seconds);
1479
- if (waited.met === "text") return `text appeared after ${seconds} s`;
1480
- if (waited.met === "textGone") return `text gone after ${seconds} s`;
1481
- if (waited.met === "time") return `waited ${seconds} s`;
1482
- return `not met after ${seconds} s`;
1483
- }
1484
- /**
1485
- * `withDigest` is for a page attached to a tool error: an error result has no
1486
- * meta, so the digest in the header is what records the delivery.
1487
- */
1488
- function renderPage(view, screenshot, omitBody = false, withDigest = false) {
1489
- const body = omitBody ? "Content follows in the next presence reminder." : pageBody(view);
1490
- const dialog = view.dialog === void 0 ? "" : `\n${renderDialog(view.dialog.type, view.dialog.message)}`;
1491
- const digest = withDigest && !omitBody ? digestOf$1(view) : void 0;
1492
- const digestAttr = digest === void 0 ? "" : attr$1("digest", digest);
1493
- return `<page${pageAttrs(view, screenshot)}${digestAttr}>${escapeBody(body)}${dialog}</page>`;
1494
- }
1495
- function renderActionResponse(response) {
1496
- const body = response.lost ? "No response arrived after sending; the action may have taken effect." : response.body !== void 0 ? response.body : response.bytes !== void 0 ? `${response.mediaType ?? "application/octet-stream"} (${String(response.bytes)} bytes)` : "";
1497
- return `<action-response${attr$1("name", response.name)}${response.lost === true ? attr$1("lost", "true") : ""}${response.status === void 0 ? "" : attr$1("status", String(response.status))}${response.location === void 0 ? "" : attr$1("location", response.location)}>${escapeBody(body)}</action-response>`;
1498
- }
1499
- function renderPageError(error) {
1500
- const tab = error.tab === void 0 ? "" : attr$1("tab", error.tab);
1501
- return `<page-error${attr$1("url", error.url)}${tab}${attr$1("reason", error.reason)}></page-error>`;
1502
- }
1503
- function renderTabs(tabs, closed) {
1504
- const lines = tabs.map((tab) => {
1505
- const title = tab.title === "" ? "" : ` — ${oneLine(tab.title)}`;
1506
- const flags = [
1507
- tab.inPresence ? "in presence" : "",
1508
- tab.mayHaveChanged ? "may have changed" : "",
1509
- tab.occupied ? "in use" : ""
1510
- ].filter((flag) => flag !== "");
1511
- const suffix = flags.length === 0 ? "" : ` ${flags.join(", ")}`;
1512
- return `${tab.tab} ${oneLine(tab.url)}${title}${suffix}`;
1513
- });
1514
- const closedLines = closed.map((note) => `closed: ${note}`);
1515
- return `<tabs>\n${escapeBody([...lines, ...closedLines].join("\n"))}\n</tabs>`;
1516
- }
1517
- function renderDialog(type, message) {
1518
- return `<dialog${attr$1("type", type)}>${escapeBody(message)}</dialog>`;
1519
- }
1520
- function renderDownload(download) {
1521
- if (download.kind === "error") {
1522
- const status = download.status === void 0 ? "" : attr$1("status", String(download.status));
1523
- return `<download-error${attr$1("url", download.url)}${status}${attr$1("reason", download.reason)}></download-error>`;
1524
- }
1525
- const media = download.mediaType === void 0 ? "" : attr$1("media-type", download.mediaType);
1526
- return `<download${attr$1("path", download.path)}${attr$1("url", download.url)}${media}${attr$1("bytes", String(download.bytes))}${attr$1("sha256", download.sha256)}${attr$1("observed", download.observed)}></download>`;
1527
- }
1528
- /**
1529
- * `omitInPresence`: the page's body is the same one presence carries, so a
1530
- * page in presence gets only its attributes.
1531
- */
1532
- function renderOutcome(pages, tabs, closed, screenshot, omitInPresence = false) {
1533
- return `${pages.map((page) => {
1534
- const main = "representation" in page ? renderPage(page, screenshot, omitInPresence && page.inPresence) : renderPageError(page);
1535
- if (!("representation" in page) || page.download === void 0) return main;
1536
- return `${main}\n${renderDownload(page.download)}`;
1537
- }).join("\n")}\n${renderTabs(tabs, closed)}`;
1538
- }
1539
- /** Reading fields persisted in tool meta. `by` marks the result as this plugin's. */
1540
- function readingsOf(pages, action) {
1541
- return {
1542
- by: "mh-browser",
1543
- action,
1544
- pages: pages.map((page) => {
1545
- if (!("representation" in page)) return {
1546
- url: page.url,
1547
- ...page.tab === void 0 ? {} : { tab: page.tab },
1548
- representation: "snapshot",
1549
- reason: page.reason
1550
- };
1551
- const version = versionOf$1(page);
1552
- const digest = digestOf$1(page);
1553
- const id = idOf(page);
1554
- return {
1555
- url: page.url,
1556
- tab: page.tab,
1557
- representation: page.representation.kind,
1558
- ...version === void 0 ? {} : { version },
1559
- ...digest === void 0 ? {} : { digest },
1560
- ...id === void 0 ? {} : { id },
1561
- ...page.incomplete === void 0 ? {} : { incomplete: incompleteText(page.incomplete) }
1562
- };
1563
- })
1564
- };
1565
- }
1566
- function pageAttrs(view, screenshot) {
1567
- const representation = view.representation;
1568
- const version = versionOf$1(view);
1569
- const agentView = representation.kind === "snapshot" && representation.agentView !== void 0 ? attr$1("agent-view", "available") : "";
1570
- return [
1571
- attr$1("url", view.url),
1572
- attr$1("tab", view.tab),
1573
- attr$1("representation", representation.kind),
1574
- version === void 0 ? "" : attr$1("version", version),
1575
- attr$1("observed", view.observedAt),
1576
- view.fragment === void 0 ? "" : attr$1("fragment", view.fragment),
1577
- view.status === void 0 ? "" : attr$1("status", String(view.status)),
1578
- view.mediaType === void 0 ? "" : attr$1("media-type", view.mediaType),
1579
- view.incomplete === void 0 ? "" : attr$1("incomplete", incompleteText(view.incomplete)),
1580
- view.waited === void 0 ? "" : attr$1("waited", waitedText(view.waited)),
1581
- view.inPresence ? attr$1("in-presence", "true") : "",
1582
- agentView,
1583
- representation.kind === "snapshot" && representation.agentViewError !== void 0 ? attr$1("agent-view-error", representation.agentViewError) : "",
1584
- screenshot === void 0 ? "" : attr$1("screenshot", screenshot)
1585
- ].join("");
1586
- }
1587
- const BODY_LIMIT = 2e5;
1588
- function pageBody(view) {
1589
- const representation = view.representation;
1590
- if (view.mediaType === void 0 || representation.kind !== "snapshot") return representation.text;
1591
- if (view.body !== void 0) {
1592
- if (view.body.length <= BODY_LIMIT) return view.body;
1593
- return `${view.body.slice(0, BODY_LIMIT)}\nCut at ${String(BODY_LIMIT)} characters; use browser_download to save the whole file into the workspace.`;
1594
- }
1595
- if (view.status !== void 0) return `The site answered HTTP ${String(view.status)}; the ${view.mediaType} body is not shown.`;
1596
- return "Not an HTML page; use browser_download to save it into the workspace.";
1597
- }
1598
- function versionOf$1(view) {
1599
- const representation = view.representation;
1600
- if (representation.kind === "agent-view") return representation.version;
1601
- return representation.agentView?.version;
1602
- }
1603
- function digestOf$1(view) {
1604
- if (view.representation.kind !== "agent-view") return void 0;
1605
- return representationDigest(view.representation.text);
1606
- }
1607
- function idOf(view) {
1608
- if (view.representation.kind !== "agent-view") return void 0;
1609
- return agentViewId(view.representation.text, view.url);
1610
- }
1611
- function attr$1(name, value) {
1612
- return ` ${name}="${escapeAttr(value)}"`;
1613
- }
1614
- function formatSeconds(seconds) {
1615
- const rounded = Math.round(seconds * 10) / 10;
1616
- return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
1617
- }
1618
- //#endregion
1619
2367
  //#region lib/types/presence/plan.js
1620
2368
  const SOURCE_ORDER = [
1621
2369
  "person via sidebar",
@@ -2083,10 +2831,14 @@ async function loadShown(input) {
2083
2831
  base.mediaType = input.main.mediaType;
2084
2832
  if (isTextual(input.main.mediaType)) base.body = input.main.body;
2085
2833
  }
2086
- if (input.main !== void 0 && isAgentViewContentType(input.main.mediaType, input.main.body) && samePage(input.main.finalUrl, input.pageUrl)) return fromDocument({
2087
- ...base,
2088
- href: input.pageUrl
2089
- }, input.main.body, input.pageUrl);
2834
+ if (input.main !== void 0 && isAgentViewContentType(input.main.mediaType, input.main.body) && samePage(input.main.finalUrl, input.pageUrl)) {
2835
+ const shown = fromDocument({
2836
+ ...base,
2837
+ href: input.pageUrl
2838
+ }, input.main.body, input.pageUrl);
2839
+ if (input.main.etag !== void 0) shown.etag = input.main.etag;
2840
+ return shown;
2841
+ }
2090
2842
  if (input.relocate) {
2091
2843
  const html = await input.fetch(input.pageUrl, "text/html");
2092
2844
  if (html.kind === "error") return failed(base, html.reason);
@@ -2225,27 +2977,43 @@ async function launchBrowser(options) {
2225
2977
  //#endregion
2226
2978
  //#region lib/types/service/control.js
2227
2979
  /**
2228
- * Who holds the browser for one session. Person input outside a service
2229
- * dispatch moves it to the person and aborts the action still waiting.
2230
- * A model handover keeps the hold until hand-back or a stopped call.
2980
+ * Who holds each tab of one session. Person input outside a service dispatch
2981
+ * moves that tab to the person and aborts the action still waiting on it.
2982
+ * A handover that names no tab holds every tab until hand-back.
2231
2983
  */
2232
2984
  var Control = class {
2233
2985
  who = /* @__PURE__ */ new Map();
2234
2986
  holding = /* @__PURE__ */ new Set();
2987
+ /** A handover that named no tab: every tab of the session is the person's. */
2988
+ blanket = /* @__PURE__ */ new Set();
2235
2989
  inflight = /* @__PURE__ */ new Map();
2236
2990
  operations = /* @__PURE__ */ new Map();
2237
2991
  tabs = /* @__PURE__ */ new Map();
2238
2992
  since = /* @__PURE__ */ new Map();
2239
2993
  named = /* @__PURE__ */ new Map();
2994
+ /** Whether the person holds any tab, or a handover is waiting. */
2240
2995
  of(owner) {
2241
2996
  return this.who.get(owner) ?? "model";
2242
2997
  }
2243
- /** Abort this session's waiting action when the caller aborts or the person takes over. */
2244
- link(owner, caller) {
2998
+ /** The person holds this tab until they hand it back. */
2999
+ ofTab(owner, tab) {
3000
+ if (this.blanket.has(owner)) return "person";
3001
+ if (this.tabs.get(owner)?.has(tab) === true) return "person";
3002
+ return "model";
3003
+ }
3004
+ /**
3005
+ * Abort this waiting action when the caller aborts, or when the person
3006
+ * takes the tab it is running on. A call with no tab (the request channel)
3007
+ * ends only when its caller aborts.
3008
+ */
3009
+ link(owner, caller, tab) {
2245
3010
  const ctrl = new AbortController();
2246
- const set = this.inflight.get(owner) ?? /* @__PURE__ */ new Set();
3011
+ const byTab = this.inflight.get(owner) ?? /* @__PURE__ */ new Map();
3012
+ const key = tab ?? "";
3013
+ const set = byTab.get(key) ?? /* @__PURE__ */ new Set();
2247
3014
  set.add(ctrl);
2248
- this.inflight.set(owner, set);
3015
+ byTab.set(key, set);
3016
+ this.inflight.set(owner, byTab);
2249
3017
  const onCaller = () => ctrl.abort();
2250
3018
  if (caller?.aborted === true) ctrl.abort();
2251
3019
  else caller?.addEventListener("abort", onCaller, { once: true });
@@ -2272,7 +3040,7 @@ var Control = class {
2272
3040
  }
2273
3041
  this.operations.set(owner, (this.operations.get(owner) ?? 0) + 1);
2274
3042
  if (tab !== void 0) this.tabs.get(owner)?.add(tab);
2275
- for (const ctrl of this.inflight.get(owner) ?? []) ctrl.abort();
3043
+ this.abort(owner, tab);
2276
3044
  }
2277
3045
  /** A handover is waiting for the person to hand the browser back. */
2278
3046
  holds(owner) {
@@ -2285,8 +3053,12 @@ var Control = class {
2285
3053
  /** The model hands the browser to the person and waits for the card. */
2286
3054
  handover(owner, tab) {
2287
3055
  this.holding.add(owner);
2288
- if (tab !== void 0) this.named.set(owner, tab);
2289
- if (this.who.get(owner) === "person") return;
3056
+ if (tab === void 0) this.blanket.add(owner);
3057
+ else this.named.set(owner, tab);
3058
+ if (this.who.get(owner) === "person") {
3059
+ if (tab !== void 0) this.tabs.get(owner)?.add(tab);
3060
+ return;
3061
+ }
2290
3062
  this.who.set(owner, "person");
2291
3063
  this.since.set(owner, (/* @__PURE__ */ new Date()).toISOString());
2292
3064
  this.operations.set(owner, 0);
@@ -2320,13 +3092,23 @@ var Control = class {
2320
3092
  }
2321
3093
  /** The Session is gone. Its in-flight calls are aborted. */
2322
3094
  forget(owner) {
2323
- for (const ctrl of this.inflight.get(owner) ?? []) ctrl.abort();
3095
+ this.abort(owner, void 0);
2324
3096
  this.inflight.delete(owner);
2325
3097
  this.clear(owner);
2326
3098
  this.who.delete(owner);
2327
3099
  }
3100
+ abort(owner, tab) {
3101
+ const byTab = this.inflight.get(owner);
3102
+ if (byTab === void 0) return;
3103
+ if (tab === void 0) {
3104
+ for (const set of byTab.values()) for (const ctrl of set) ctrl.abort();
3105
+ return;
3106
+ }
3107
+ for (const ctrl of byTab.get(tab) ?? []) ctrl.abort();
3108
+ }
2328
3109
  clear(owner) {
2329
3110
  this.holding.delete(owner);
3111
+ this.blanket.delete(owner);
2330
3112
  this.who.set(owner, "model");
2331
3113
  this.operations.delete(owner);
2332
3114
  this.tabs.delete(owner);
@@ -2441,75 +3223,65 @@ const KEPT_KEYWORDS = /* @__PURE__ */ new Set([
2441
3223
  "default",
2442
3224
  "examples"
2443
3225
  ]);
2444
- async function runInvoke(host, pageUrl, call) {
2445
- const view = await host.idleView(pageUrl) ?? await host.open(pageUrl);
2446
- if (!("representation" in view)) throw new MhBrowserError("NOT_DECLARED", view.reason);
2447
- const action = declaredAction(view, call.name);
2448
- if (call.seen === "not-in-context") throw new MhBrowserError("STALE_VIEW", "not-in-context", view);
2449
- if (viewChanged(call.seen, view)) throw new MhBrowserError("STALE_VIEW", "changed", view);
2450
- const doc = readableDoc(view);
2451
- const origin = view.url === "" ? pageUrl : view.url;
2452
- const plan = planAction(doc, action, origin, call);
3226
+ async function runInvoke(host, call) {
3227
+ const origin = call.shown.url;
3228
+ const read = readAgentView(call.shown.text, origin);
3229
+ if (!read.ok) throw new MhBrowserError("NOT_DECLARED", read.reason);
3230
+ const action = read.doc.actions[call.name];
3231
+ if (action === void 0) throw new MhBrowserError("NOT_DECLARED", `no declared action named ${call.name}`);
3232
+ const plan = planAction(read.doc, action, origin, call);
2453
3233
  if (plan.method === "GET") {
2454
- const landed = await host.navigate(view.tab, withQuery(plan.url, plan.query));
3234
+ const landed = await land(host, withQuery(plan.url, plan.query));
2455
3235
  return {
2456
3236
  schemaRejected: plan.schemaRejected,
2457
3237
  page: landed
2458
3238
  };
2459
- }
2460
- const target = plan.method === "DELETE" ? withQuery(plan.url, plan.query) : plan.url;
2461
- const sent = await host.send(await requestOf(host, plan, target));
2462
- if (sent.kind === "lost") return {
2463
- schemaRejected: plan.schemaRejected,
2464
- response: {
2465
- name: call.name,
2466
- lost: true
2467
- },
2468
- page: view
2469
- };
2470
- const response = actionResponse(call.name, sent, plan.url);
2471
- if (response.location !== void 0 && sameOrigin(response.location, origin)) {
2472
- const landed = await host.navigate(view.tab, response.location);
3239
+ }
3240
+ const target = plan.method === "DELETE" ? withQuery(plan.url, plan.query) : plan.url;
3241
+ const sent = await host.send(await requestOf(host, plan, target));
3242
+ if (sent.kind === "lost") {
3243
+ const page = header(await landAfterSend(host, origin));
2473
3244
  return {
2474
3245
  schemaRejected: plan.schemaRejected,
2475
- response,
2476
- page: landed
3246
+ response: {
3247
+ name: call.name,
3248
+ lost: true
3249
+ },
3250
+ page
2477
3251
  };
2478
3252
  }
2479
- if (response.location !== void 0) return {
2480
- schemaRejected: plan.schemaRejected,
2481
- response,
2482
- page: view
2483
- };
2484
- const reloaded = await host.reload(pageUrl, view.tab);
3253
+ const response = actionResponse(call.name, sent, plan.url);
3254
+ const follows = response.location !== void 0 && sameOrigin(response.location, origin) ? response.location : origin;
2485
3255
  return {
2486
3256
  schemaRejected: plan.schemaRejected,
2487
3257
  response,
2488
- page: reloaded
3258
+ page: header(await landAfterSend(host, follows))
2489
3259
  };
2490
3260
  }
2491
- /**
2492
- * A page that declares a `version` changed only when that version did: bytes
2493
- * that are not the resource, such as links to the rest of the site, move
2494
- * without making the model's choice stale. The site's own version precondition
2495
- * in `request` rejects a choice made on an old version.
2496
- */
2497
- function viewChanged(seen, view) {
2498
- const version = view.representation.kind === "agent-view" ? view.representation.version : void 0;
2499
- if (seen.version !== void 0 || version !== void 0) return seen.version !== version;
2500
- return seen.digest !== representationDigest(view.representation.text);
2501
- }
2502
- function declaredAction(view, name) {
2503
- if (view.representation.kind !== "agent-view") throw new MhBrowserError("NOT_DECLARED", (view.representation.kind === "snapshot" ? view.representation.agentViewError : void 0) ?? "this page has no readable Agent View", view);
2504
- const action = readableDoc(view).actions[name];
2505
- if (action === void 0) throw new MhBrowserError("NOT_DECLARED", `no declared action named ${name}`, view);
2506
- return action;
2507
- }
2508
- function readableDoc(view) {
2509
- if (view.representation.kind !== "agent-view") throw new MhBrowserError("NOT_DECLARED", "this page has no readable Agent View", view);
2510
- const read = readAgentView(view.representation.text, view.url);
2511
- if (!read.ok) throw new MhBrowserError("NOT_DECLARED", read.reason, view);
2512
- return read.doc;
3261
+ /** The request already went out, so a failed landing is reported on the page and the response is kept. */
3262
+ async function landAfterSend(host, url) {
3263
+ try {
3264
+ return await land(host, url);
3265
+ } catch (error) {
3266
+ return {
3267
+ url,
3268
+ reason: error instanceof Error ? error.message : String(error)
3269
+ };
3270
+ }
3271
+ }
3272
+ /** Open the address, then reload any other idle tab still showing it. */
3273
+ async function land(host, url) {
3274
+ const page = await host.open(url);
3275
+ await host.refreshIdle(url, "tab" in page ? page.tab : void 0);
3276
+ return page;
3277
+ }
3278
+ /** A non-GET action shows the landing page's header and does not deliver it. */
3279
+ function header(page) {
3280
+ if (!("representation" in page)) return page;
3281
+ return {
3282
+ ...page,
3283
+ headerOnly: true
3284
+ };
2513
3285
  }
2514
3286
  function planAction(doc, action, pageUrl, call) {
2515
3287
  const input = objectInput(call.input);
@@ -2731,84 +3503,386 @@ function isRecord(value) {
2731
3503
  }
2732
3504
  //#endregion
2733
3505
  //#region lib/types/service/actions.js
2734
- /** Run one snapshot action. Dialog, wait, and look are handled by the service. */
2735
- async function runAction(page, action, signal) {
3506
+ const REF = /^(?:f\d+)?e\d+$/;
3507
+ const TIMEOUT = 1e4;
3508
+ /** A page operation returns a snapshot after it. The rest return one sentence. */
3509
+ function showsSnapshot(op) {
3510
+ if (op.kind === "press_key") return op.key === "Enter";
3511
+ if (op.kind === "type") return op.slowly === true || op.submit === true;
3512
+ return op.kind === "click" || op.kind === "hover" || op.kind === "drag" || op.kind === "select_option" || op.kind === "file_upload" || op.kind === "navigate_back" || op.kind === "wait_for" || op.kind === "snapshot" || op.kind === "take_screenshot";
3513
+ }
3514
+ /** The sentence for an operation whose result has no snapshot. */
3515
+ function summaryOf(op) {
3516
+ if (op.kind === "fill_form") return `Filled ${String(op.fields.length)} fields.`;
3517
+ if (op.kind === "type") return `Typed into ${op.element ?? op.target}.`;
3518
+ if (op.kind === "press_key") return `Pressed ${op.key}.`;
3519
+ if (op.kind === "handle_dialog") return op.accept ? "Dialog accepted." : "Dialog dismissed.";
3520
+ }
3521
+ /** Run one snapshot action. Dialog, wait, upload, find, and evaluate are handled by the service. */
3522
+ async function runAction(page, op, snapshot, signal) {
2736
3523
  let navigated = false;
2737
3524
  const onNav = (frame) => {
2738
3525
  if (frame.parentFrame() === null) navigated = true;
2739
3526
  };
2740
3527
  page.on("framenavigated", onNav);
2741
3528
  try {
2742
- await dispatch(page, action, signal);
3529
+ await dispatch(page, op, snapshot, signal);
2743
3530
  return navigated;
3531
+ } catch (error) {
3532
+ if (error instanceof MhBrowserError) throw error;
3533
+ const message = error instanceof Error ? error.message : String(error);
3534
+ const first = message.split("\n")[0] ?? message;
3535
+ if (first.includes("strict mode violation")) throw new MhBrowserError("INVALID_ARGS", first);
3536
+ throw error;
2744
3537
  } finally {
2745
3538
  page.off("framenavigated", onNav);
2746
3539
  }
2747
3540
  }
2748
- async function dispatch(page, action, signal) {
3541
+ async function locate(page, target, snapshot) {
3542
+ if (REF.test(target)) {
3543
+ if (!snapshot.includes(`[ref=${target}]`)) throw new MhBrowserError("STALE_REF", `Ref ${target} not found in the current page snapshot. Try capturing new snapshot.`);
3544
+ return page.getByRef(target);
3545
+ }
3546
+ const found = await page.$(target);
3547
+ if (found === null) throw new MhBrowserError("STALE_REF", `"${target}" does not match any elements.`);
3548
+ await found.dispose();
3549
+ return page.locator(target);
3550
+ }
3551
+ async function dispatch(page, op, snapshot, signal) {
2749
3552
  const options = signal === void 0 ? {} : { signal };
2750
- if (action.kind === "click") {
2751
- await page.getByRef(action.ref).click({
2752
- button: action.button ?? "left",
2753
- clickCount: action.double === true ? 2 : 1,
2754
- ...action.modifiers !== void 0 ? { modifiers: action.modifiers } : {},
2755
- timeout: 1e4,
3553
+ if (op.kind === "click") {
3554
+ await (await locate(page, op.target, snapshot)).click({
3555
+ button: op.button ?? "left",
3556
+ clickCount: op.doubleClick === true ? 2 : 1,
3557
+ ...op.modifiers !== void 0 ? { modifiers: op.modifiers } : {},
3558
+ timeout: TIMEOUT,
2756
3559
  ...options
2757
3560
  });
2758
3561
  return;
2759
3562
  }
2760
- if (action.kind === "select") {
2761
- if (action.values.length === 0) throw new MhBrowserError("INVALID_ARGS", "select needs at least one value");
2762
- await page.getByRef(action.ref).selectOption(action.values, {
2763
- timeout: 1e4,
3563
+ if (op.kind === "hover") {
3564
+ await (await locate(page, op.target, snapshot)).hover({
3565
+ timeout: TIMEOUT,
2764
3566
  ...options
2765
3567
  });
2766
3568
  return;
2767
3569
  }
2768
- if (action.kind === "press") {
2769
- if (action.ref === void 0) await page.keyboard.press(action.key);
2770
- else await page.getByRef(action.ref).press(action.key, {
2771
- timeout: 1e4,
3570
+ if (op.kind === "select_option") {
3571
+ if (op.values.length === 0) throw new MhBrowserError("INVALID_ARGS", "select_option needs at least one value");
3572
+ await (await locate(page, op.target, snapshot)).selectOption(op.values, {
3573
+ timeout: TIMEOUT,
2772
3574
  ...options
2773
3575
  });
2774
3576
  return;
2775
3577
  }
2776
- if (action.kind === "fill") {
2777
- if (action.fields.length === 0) throw new MhBrowserError("INVALID_ARGS", "fill needs at least one field");
2778
- for (const field of action.fields) {
2779
- const locator = page.getByRef(field.ref);
2780
- if (await locator.evaluate((element) => element.tagName === "INPUT" ? element.getAttribute("type") ?? "text" : "") === "checkbox") {
2781
- if (field.value === "true") await locator.check({
2782
- timeout: 1e4,
2783
- ...options
2784
- });
2785
- else if (field.value === "false") await locator.uncheck({
2786
- timeout: 1e4,
3578
+ if (op.kind === "drag") {
3579
+ const start = await locate(page, op.startTarget, snapshot);
3580
+ const end = await locate(page, op.endTarget, snapshot);
3581
+ await start.dragTo(end, {
3582
+ timeout: TIMEOUT,
3583
+ ...options
3584
+ });
3585
+ return;
3586
+ }
3587
+ if (op.kind === "type") {
3588
+ const locator = await locate(page, op.target, snapshot);
3589
+ if (op.slowly === true) await locator.pressSequentially(op.text, {
3590
+ timeout: TIMEOUT,
3591
+ ...options
3592
+ });
3593
+ else await locator.fill(op.text, {
3594
+ timeout: TIMEOUT,
3595
+ ...options
3596
+ });
3597
+ if (op.submit === true) await locator.press("Enter", {
3598
+ timeout: TIMEOUT,
3599
+ ...options
3600
+ });
3601
+ return;
3602
+ }
3603
+ if (op.kind === "fill_form") {
3604
+ if (op.fields.length === 0) throw new MhBrowserError("INVALID_ARGS", "fill_form needs at least one field");
3605
+ for (const field of op.fields) {
3606
+ const locator = await locate(page, field.target, snapshot);
3607
+ if (field.type === "checkbox" || field.type === "radio") {
3608
+ if (field.value !== "true" && field.value !== "false") throw new MhBrowserError("INVALID_ARGS", "a checkbox value is true or false");
3609
+ await locator.setChecked(field.value === "true", {
3610
+ timeout: TIMEOUT,
2787
3611
  ...options
2788
3612
  });
2789
- else throw new MhBrowserError("INVALID_ARGS", "a checkbox value is true or false");
2790
- } else await locator.fill(field.value, {
2791
- timeout: 1e4,
3613
+ } else if (field.type === "combobox") await locator.selectOption({ label: field.value }, {
3614
+ timeout: TIMEOUT,
3615
+ ...options
3616
+ });
3617
+ else await locator.fill(field.value, {
3618
+ timeout: TIMEOUT,
2792
3619
  ...options
2793
3620
  });
2794
3621
  }
2795
3622
  return;
2796
3623
  }
2797
- if (action.kind === "upload") {
2798
- if (action.files.length === 0) throw new MhBrowserError("INVALID_ARGS", "upload needs at least one file");
2799
- await page.getByRef(action.ref).setInputFiles(action.files, {
2800
- timeout: 1e4,
2801
- ...options
2802
- });
3624
+ if (op.kind === "press_key") {
3625
+ await page.keyboard.press(op.key);
2803
3626
  return;
2804
3627
  }
2805
- await page.goBack({
3628
+ if (op.kind === "navigate_back") await page.goBack({
2806
3629
  waitUntil: "commit",
2807
- timeout: 1e4,
3630
+ timeout: TIMEOUT,
2808
3631
  ...options
2809
3632
  });
2810
3633
  }
2811
3634
  //#endregion
3635
+ //#region lib/types/service/evaluate.js
3636
+ /** How long requests the function started are still collected after it returns. */
3637
+ const SETTLE_MS$1 = 50;
3638
+ /**
3639
+ * Run the expression in the page and collect write requests.
3640
+ * A request is from this call when its initiator stack, including parents,
3641
+ * has a frame from the sourceURL marker. Anything else is contemporaneous.
3642
+ * GET, HEAD, and OPTIONS are not writes. A dialog, a stop, or a navigation
3643
+ * ends the wait; requests already sent are reported either way.
3644
+ */
3645
+ async function evaluateMarked(page, source, target, callId, signal, dialog) {
3646
+ const marker = `mh-evaluate-${callId}-${randomUUID().slice(0, 8)}`;
3647
+ const client = await page.context().newCDPSession(page);
3648
+ const notes = /* @__PURE__ */ new Map();
3649
+ const onRequest = (event) => {
3650
+ const method = event.request?.method?.toUpperCase();
3651
+ const url = event.request?.url;
3652
+ if (method === void 0 || url === void 0) return;
3653
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS") return;
3654
+ const id = event.requestId ?? `${String(notes.size)}`;
3655
+ if (notes.has(id)) return;
3656
+ notes.set(id, {
3657
+ method,
3658
+ origin: originOf(url),
3659
+ fromCall: stackHas(event.initiator?.stack, marker)
3660
+ });
3661
+ };
3662
+ const writes = () => countWrites([...notes.values()]);
3663
+ client.on("Network.requestWillBeSent", onRequest);
3664
+ const stop = whenAborted(signal);
3665
+ let dialogOpen = false;
3666
+ try {
3667
+ await client.send("Network.enable");
3668
+ await client.send("Debugger.enable");
3669
+ await client.send("Debugger.setSkipAllPauses", { skip: true });
3670
+ await client.send("Debugger.setAsyncCallStackDepth", { maxDepth: 32 });
3671
+ const evaluation = (target === void 0 ? page.evaluate(runSource, {
3672
+ expr: source,
3673
+ marker
3674
+ }) : target.evaluate(runSourceOn, {
3675
+ expr: source,
3676
+ marker
3677
+ })).then((raw) => ({
3678
+ kind: "value",
3679
+ raw
3680
+ }), (error) => ({
3681
+ kind: "error",
3682
+ error
3683
+ }));
3684
+ const winner = await Promise.race([
3685
+ evaluation,
3686
+ stop.promise.then(() => ({ kind: "stopped" })),
3687
+ dialog.then((shown) => ({
3688
+ kind: "dialog",
3689
+ dialog: shown
3690
+ }))
3691
+ ]);
3692
+ if (winner.kind === "stopped") return {
3693
+ kind: "stopped",
3694
+ writes: writes()
3695
+ };
3696
+ if (winner.kind === "dialog") {
3697
+ dialogOpen = true;
3698
+ return {
3699
+ kind: "dialog",
3700
+ dialog: winner.dialog,
3701
+ writes: writes()
3702
+ };
3703
+ }
3704
+ if (winner.kind === "error") {
3705
+ if (!contextGone(winner.error)) throw winner.error;
3706
+ await delay(SETTLE_MS$1);
3707
+ return {
3708
+ kind: "navigated",
3709
+ writes: writes()
3710
+ };
3711
+ }
3712
+ await delay(SETTLE_MS$1);
3713
+ return {
3714
+ kind: "value",
3715
+ value: JSON.stringify(winner.raw, null, 2) ?? "undefined",
3716
+ writes: writes()
3717
+ };
3718
+ } finally {
3719
+ stop.dispose();
3720
+ client.off("Network.requestWillBeSent", onRequest);
3721
+ const detached = client.detach().catch(() => void 0);
3722
+ if (!dialogOpen) await detached;
3723
+ }
3724
+ }
3725
+ /** One line per group: `POST https://site × 2 (this call)`. */
3726
+ function formatWrites(writes) {
3727
+ return writes.map((note) => `${note.method} ${note.origin} × ${String(note.count)} (${note.fromCall ? "this call" : "contemporaneous"})`).join("\n");
3728
+ }
3729
+ function countWrites(notes) {
3730
+ const counts = /* @__PURE__ */ new Map();
3731
+ for (const note of notes) {
3732
+ const key = `${note.method} ${note.origin} ${note.fromCall ? "call" : "page"}`;
3733
+ const found = counts.get(key);
3734
+ if (found === void 0) counts.set(key, {
3735
+ ...note,
3736
+ count: 1
3737
+ });
3738
+ else found.count += 1;
3739
+ }
3740
+ return [...counts.values()];
3741
+ }
3742
+ function whenAborted(signal) {
3743
+ if (signal === void 0) return {
3744
+ promise: new Promise(() => void 0),
3745
+ dispose: () => {}
3746
+ };
3747
+ if (signal.aborted) return {
3748
+ promise: Promise.resolve(),
3749
+ dispose: () => {}
3750
+ };
3751
+ let onAbort = () => {};
3752
+ return {
3753
+ promise: new Promise((resolve) => {
3754
+ onAbort = () => resolve();
3755
+ signal.addEventListener("abort", onAbort, { once: true });
3756
+ }),
3757
+ dispose: () => signal.removeEventListener("abort", onAbort)
3758
+ };
3759
+ }
3760
+ function contextGone(error) {
3761
+ return (error instanceof Error ? error.message : String(error)).includes("Execution context was destroyed");
3762
+ }
3763
+ function delay(ms) {
3764
+ return new Promise((resolve) => setTimeout(resolve, ms));
3765
+ }
3766
+ function runSource(input) {
3767
+ const value = eval(`(${input.expr})\n//# sourceURL=${input.marker}`);
3768
+ return typeof value === "function" ? value() : value;
3769
+ }
3770
+ function runSourceOn(element, input) {
3771
+ const value = eval(`(${input.expr})\n//# sourceURL=${input.marker}`);
3772
+ return typeof value === "function" ? value(element) : value;
3773
+ }
3774
+ function stackHas(stack, marker) {
3775
+ for (let cursor = stack; cursor !== void 0; cursor = cursor.parent) for (const frame of cursor.callFrames ?? []) if (frame.url === marker) return true;
3776
+ return false;
3777
+ }
3778
+ function originOf(url) {
3779
+ try {
3780
+ return new URL(url).origin;
3781
+ } catch {
3782
+ return url;
3783
+ }
3784
+ }
3785
+ //#endregion
3786
+ //#region lib/types/service/find.js
3787
+ const CONTEXT_LINES = 3;
3788
+ /** Search one snapshot. The header and the miss sentence are what the model reads. */
3789
+ function findInSnapshot(snapshot, query) {
3790
+ if (query.text === void 0 && query.regex === void 0 || query.text !== void 0 && query.regex !== void 0) throw new MhBrowserError("INVALID_ARGS", query.text !== void 0 && query.regex !== void 0 ? "Provide only one of \"text\" or \"regex\", not both." : "Provide either \"text\" or \"regex\" to search for.");
3791
+ if (query.maxResults !== void 0 && (!Number.isInteger(query.maxResults) || query.maxResults <= 0)) throw new MhBrowserError("INVALID_ARGS", "\"maxResults\" must be a positive integer.");
3792
+ const matched = matcher(query.text, query.regex);
3793
+ const lines = snapshot.split("\n");
3794
+ const indents = lines.map(indentOf);
3795
+ const hits = [];
3796
+ for (let index = 0; index < lines.length; index += 1) {
3797
+ const line = lines[index];
3798
+ if (line !== void 0 && matched.test(line)) hits.push(index);
3799
+ }
3800
+ const label = matched.label;
3801
+ if (hits.length === 0) return `No matches found for ${label}.`;
3802
+ const shown = query.maxResults === void 0 ? hits : hits.slice(0, query.maxResults);
3803
+ const windows = mergeWindows(shown, lines.length);
3804
+ const onPath = /* @__PURE__ */ new Set();
3805
+ for (const hit of shown) {
3806
+ onPath.add(hit);
3807
+ for (const ancestor of ancestorIndices(lines, indents, hit)) onPath.add(ancestor);
3808
+ }
3809
+ const snippets = windows.map((window) => {
3810
+ const indices = ancestorIndices(lines, indents, window.start);
3811
+ for (let index = window.start; index <= window.end; index += 1) indices.push(index);
3812
+ const out = [];
3813
+ for (let index = 0; index < indices.length; index += 1) {
3814
+ const lineNo = indices[index];
3815
+ const previous = indices[index - 1];
3816
+ if (lineNo !== void 0 && previous !== void 0 && lineNo > previous + 1 && !onPath.has(lineNo) && !onPath.has(previous)) out.push(`${" ".repeat(indents[lineNo] ?? 0)}...`);
3817
+ const line = lineNo === void 0 ? void 0 : lines[lineNo];
3818
+ if (line !== void 0) out.push(line);
3819
+ }
3820
+ return out.join("\n");
3821
+ });
3822
+ return `${shown.length < hits.length ? `Found ${String(hits.length)} matches for ${label} (showing first ${String(shown.length)}):` : `Found ${String(hits.length)} matches for ${label}:`}\n\n${snippets.join("\n\n----\n\n")}`;
3823
+ }
3824
+ function matcher(text, regex) {
3825
+ if (regex !== void 0) {
3826
+ let pattern;
3827
+ try {
3828
+ pattern = compileRegex(regex);
3829
+ } catch {
3830
+ throw new MhBrowserError("INVALID_ARGS", "Invalid regular expression");
3831
+ }
3832
+ return {
3833
+ label: String(pattern),
3834
+ test(line) {
3835
+ pattern.lastIndex = 0;
3836
+ return pattern.test(line);
3837
+ }
3838
+ };
3839
+ }
3840
+ const needle = (text ?? "").toLowerCase();
3841
+ return {
3842
+ label: `"${text ?? ""}"`,
3843
+ test(line) {
3844
+ return line.toLowerCase().includes(needle);
3845
+ }
3846
+ };
3847
+ }
3848
+ function compileRegex(source) {
3849
+ const literal = /^\/(.*)\/([a-z]*)$/.exec(source);
3850
+ const pattern = literal?.[1] ?? source;
3851
+ const flags = literal?.[2]?.replace(/g/g, "") ?? "";
3852
+ return new RegExp(pattern, flags);
3853
+ }
3854
+ function indentOf(line) {
3855
+ return line.length - line.trimStart().length;
3856
+ }
3857
+ function ancestorIndices(lines, indents, index) {
3858
+ const found = [];
3859
+ let indent = indents[index] ?? 0;
3860
+ for (let cursor = index - 1; cursor >= 0 && indent > 0; cursor -= 1) {
3861
+ const line = lines[cursor];
3862
+ if (line === void 0 || line.trim() === "") continue;
3863
+ const next = indents[cursor] ?? 0;
3864
+ if (next < indent) {
3865
+ found.push(cursor);
3866
+ indent = next;
3867
+ }
3868
+ }
3869
+ return found.reverse();
3870
+ }
3871
+ function mergeWindows(hits, length) {
3872
+ const windows = [];
3873
+ for (const hit of hits) {
3874
+ const start = Math.max(0, hit - CONTEXT_LINES);
3875
+ const end = Math.min(length - 1, hit + CONTEXT_LINES);
3876
+ const last = windows[windows.length - 1];
3877
+ if (last !== void 0 && start <= last.end + 1) last.end = Math.max(last.end, end);
3878
+ else windows.push({
3879
+ start,
3880
+ end
3881
+ });
3882
+ }
3883
+ return windows;
3884
+ }
3885
+ //#endregion
2812
3886
  //#region lib/types/service/waits.js
2813
3887
  const LOAD_MS = 5e3;
2814
3888
  const WAIT_CAP_MS = 3e4;
@@ -3309,6 +4383,7 @@ var Windows = class {
3309
4383
  tab.inputHeld = false;
3310
4384
  tab.mayHaveChanged = false;
3311
4385
  tab.occupied = tab.dialogHeld || tab.handoverHeld;
4386
+ tab.fileChooser = void 0;
3312
4387
  }
3313
4388
  markMayHaveChanged(tab) {
3314
4389
  tab.mayHaveChanged = true;
@@ -3442,11 +4517,16 @@ var Windows = class {
3442
4517
  await this.arm(page, owner, tab.id);
3443
4518
  return tab;
3444
4519
  }
3445
- /** Close one tab the service opened for a presence check. */
4520
+ /**
4521
+ * Close one tab. `note` is listed in the next `<tabs>` block. A tab the
4522
+ * model was never shown (an Agent View discovered on a new tab) omits it.
4523
+ */
3446
4524
  async closeTab(tab, note) {
3447
- const notes = this.closedNotes.get(tab.owner) ?? [];
3448
- notes.push(note);
3449
- this.closedNotes.set(tab.owner, notes);
4525
+ if (note !== void 0) {
4526
+ const notes = this.closedNotes.get(tab.owner) ?? [];
4527
+ notes.push(note);
4528
+ this.closedNotes.set(tab.owner, notes);
4529
+ }
3450
4530
  await this.closePage(tab);
3451
4531
  }
3452
4532
  async closeOwner(owner) {
@@ -3845,6 +4925,7 @@ function idleTab() {
3845
4925
  handoverHeld: false,
3846
4926
  inPresence: false,
3847
4927
  mayHaveChanged: false,
4928
+ fileChooser: void 0,
3848
4929
  pendingDialog: void 0,
3849
4930
  dialogWaiters: [],
3850
4931
  pendingDownload: void 0,
@@ -3919,6 +5000,8 @@ const RELAUNCH_LIMIT = 3;
3919
5000
  const RELAUNCH_WINDOW_MS = 6e4;
3920
5001
  const STOP_WAIT_MS = 5e3;
3921
5002
  const WAITING_STOPPED = "stopped while waiting";
5003
+ const STOPPED = Symbol("stopped");
5004
+ const NAVIGATED_DURING_EVALUATE = "The page navigated before the function returned.";
3922
5005
  const MAY_HAVE_TAKEN_EFFECT = "taken over by the person; the action may have partly taken effect";
3923
5006
  const NO_WORKSPACE_REASON = "this session has no working directory";
3924
5007
  /**
@@ -3970,6 +5053,10 @@ let MhBrowserService = (() => {
3970
5053
  workspaces = /* @__PURE__ */ new Map();
3971
5054
  control = new Control();
3972
5055
  personTouch = /* @__PURE__ */ new Set();
5056
+ /** Origins that have served a readable Agent View. Memory only; a restart clears it. */
5057
+ agentViewOrigins = /* @__PURE__ */ new Set();
5058
+ /** The last Agent View read for a page address, so a later read can send If-None-Match. */
5059
+ documents = /* @__PURE__ */ new Map();
3973
5060
  constructor(ctx, config) {
3974
5061
  super(ctx, "mhBrowser");
3975
5062
  this.maxBytes = config.presence.maxBytes;
@@ -4002,28 +5089,30 @@ let MhBrowserService = (() => {
4002
5089
  await this.stop();
4003
5090
  });
4004
5091
  }
5092
+ /** Remember an origin whose pages offer Agent Views. A missing page does not remove it. */
5093
+ declareAgentViewOrigin(origin) {
5094
+ try {
5095
+ const parsed = new URL(origin);
5096
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
5097
+ this.agentViewOrigins.add(parsed.origin);
5098
+ } catch {}
5099
+ }
4005
5100
  async open(owner, items, signal) {
4006
5101
  return this.exclusive(owner, async () => {
4007
5102
  await this.ready;
4008
- this.refuseIfPerson(owner);
4009
- const linked = this.control.link(owner, signal);
4010
5103
  const views = [];
4011
- try {
4012
- for (const item of items) {
4013
- if (signal?.aborted) {
4014
- views.push({
4015
- url: item.url,
4016
- reason: WAITING_STOPPED
4017
- });
4018
- continue;
4019
- }
4020
- views.push(await this.openOne(owner, item.url, item.waitFor, linked.signal));
5104
+ for (const item of items) {
5105
+ if (signal?.aborted) {
5106
+ views.push({
5107
+ url: item.url,
5108
+ reason: WAITING_STOPPED
5109
+ });
5110
+ continue;
4021
5111
  }
4022
- await this.enforceCap(owner);
4023
- return views;
4024
- } finally {
4025
- linked.release();
5112
+ views.push(await this.openAddress(owner, item, signal));
4026
5113
  }
5114
+ await this.enforceCap(owner);
5115
+ return views;
4027
5116
  });
4028
5117
  }
4029
5118
  async view(owner, tab, signal) {
@@ -4137,7 +5226,8 @@ let MhBrowserService = (() => {
4137
5226
  if (opts?.ifNoneMatch !== void 0) headers["if-none-match"] = opts.ifNoneMatch;
4138
5227
  const response = await context.request.get(url, {
4139
5228
  headers,
4140
- failOnStatusCode: false
5229
+ failOnStatusCode: false,
5230
+ ...opts?.signal !== void 0 ? { signal: opts.signal } : {}
4141
5231
  });
4142
5232
  const headerMap = response.headers();
4143
5233
  const body = {
@@ -4181,24 +5271,32 @@ let MhBrowserService = (() => {
4181
5271
  page: (owner, tab) => this.windows.get(owner, tab)?.page
4182
5272
  };
4183
5273
  }
4184
- async act(owner, tab, action, signal) {
5274
+ async act(owner, tab, op, signal) {
4185
5275
  return this.exclusive(owner, async () => {
4186
5276
  await this.ready;
4187
- this.refuseIfPerson(owner);
5277
+ this.refuseIfPerson(owner, tab);
4188
5278
  const live = this.windows.get(owner, tab);
4189
5279
  if (live === void 0) throw new MhBrowserError(UNKNOWN_TAB, UNKNOWN_TAB_REASON);
4190
5280
  if (signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
4191
- const linked = this.control.link(owner, signal);
5281
+ const linked = this.control.link(owner, signal, tab);
4192
5282
  try {
4193
- if (live.dialogHeld && action.kind !== "dialog" && action.kind !== "look") throw new MhBrowserError("INVALID_ARGS", "this page has a dialog open; accept or dismiss it with dialog first", await this.blockedView(live));
4194
- if (missingRef(this.shown.get(live.id)?.snapshot ?? "", action) !== void 0) throw new MhBrowserError("STALE_REF", "the ref is not in this page's current snapshot", await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" }));
4195
- const outcome = await this.perform(live, action, linked.signal);
4196
- if (action.kind === "fill" || action.kind === "select" || action.kind === "upload") {
5283
+ if (live.dialogHeld && op.kind !== "handle_dialog") throw new MhBrowserError("MODAL_STATE", "a dialog is open; use browser_handle_dialog", await this.blockedView(live));
5284
+ if (live.fileChooser !== void 0 && op.kind !== "file_upload") {
5285
+ const blocked = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" });
5286
+ blocked.dialog = {
5287
+ type: "filechooser",
5288
+ message: live.fileChooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
5289
+ };
5290
+ throw new MhBrowserError("MODAL_STATE", "a file chooser is open; use browser_file_upload", blocked);
5291
+ }
5292
+ const outcome = await this.perform(live, op, linked.signal);
5293
+ if (op.kind === "fill_form" || op.kind === "select_option" || op.kind === "type" || op.kind === "file_upload") {
4197
5294
  if (outcome.navigated) this.windows.onNavigated(live);
4198
5295
  else this.windows.holdInput(live);
4199
5296
  } else if (outcome.navigated) this.windows.onNavigated(live);
4200
5297
  this.windows.touch(live);
4201
5298
  const shown = outcome.navigated ? await this.discover(live, live.page.url(), null) : await this.refreshSnapshot(live);
5299
+ const scoped = op.kind === "snapshot" && (op.target !== void 0 || op.depth !== void 0 || op.boxes === true) ? await this.scopedSnapshot(live, op) : void 0;
4202
5300
  this.shown.set(live.id, shown);
4203
5301
  this.windows.noteUrl(live, live.page.url());
4204
5302
  const view = await this.pageFromShown(live, shown, {
@@ -4207,9 +5305,23 @@ let MhBrowserService = (() => {
4207
5305
  ...outcome.incomplete !== void 0 ? { incomplete: outcome.incomplete } : {},
4208
5306
  ...outcome.waited !== void 0 ? { waited: outcome.waited } : {}
4209
5307
  });
5308
+ if (scoped !== void 0 && view.representation.kind === "snapshot") view.representation = {
5309
+ ...view.representation,
5310
+ text: scoped
5311
+ };
5312
+ if (!showsSnapshot(op)) {
5313
+ const summary = outcome.summary ?? summaryOf(op);
5314
+ if (summary !== void 0) view.summary = summary;
5315
+ if (outcome.detail !== void 0 && outcome.detail !== "") view.detail = outcome.detail;
5316
+ }
5317
+ if (op.kind === "take_screenshot") view.summary = "";
5318
+ if (outcome.shot !== void 0) view.shot = outcome.shot;
5319
+ if (outcome.writes !== void 0) view.writes = outcome.writes;
4210
5320
  return this.attachDownload(live, view, false);
4211
5321
  } catch (error) {
4212
- if (this.control.of(owner) === "person") throw await this.takenOver(live);
5322
+ if (error instanceof MhBrowserError && error.code === "TAKEN_OVER") throw error;
5323
+ if (this.control.ofTab(owner, tab) === "person") throw await this.takenOver(live);
5324
+ 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" }));
4213
5325
  throw error;
4214
5326
  } finally {
4215
5327
  linked.release();
@@ -4242,50 +5354,30 @@ let MhBrowserService = (() => {
4242
5354
  }
4243
5355
  });
4244
5356
  }
4245
- invoke(owner, page, call, opts) {
5357
+ invoke(owner, _page, call, opts) {
4246
5358
  return this.exclusive(owner, async () => {
4247
5359
  await this.ready;
4248
- this.refuseIfPerson(owner);
4249
5360
  if (opts?.signal?.aborted) throw new MhBrowserError(INTERRUPTED, WAITING_STOPPED);
4250
- const linked = this.control.link(owner, opts?.signal);
4251
- const signal = linked.signal;
4252
- try {
4253
- const sent = await runInvoke({
4254
- idleView: (url) => this.idleView(owner, url),
4255
- open: (url) => this.openOne(owner, url, void 0, signal),
4256
- navigate: (tab, url) => this.navigateOwned(owner, tab, url, signal),
4257
- reload: (url, tab) => this.reloadSame(owner, url, tab, signal),
4258
- send: (request) => this.sendAction(request, signal),
4259
- readFile: opts?.readFile ?? (async () => {
4260
- throw new MhBrowserError("NO_WORKSPACE", NO_WORKSPACE_REASON);
4261
- })
4262
- }, page, call);
4263
- await this.enforceCap(owner);
4264
- return sent;
4265
- } catch (error) {
4266
- if (this.control.of(owner) === "person") throw new MhBrowserError("TAKEN_OVER", MAY_HAVE_TAKEN_EFFECT);
4267
- throw error;
4268
- } finally {
4269
- linked.release();
4270
- }
5361
+ const signal = opts?.signal;
5362
+ const sent = await runInvoke({
5363
+ open: (url) => this.openAddress(owner, { url }, signal),
5364
+ refreshIdle: (url, except) => this.refreshIdle(owner, url, except, signal),
5365
+ send: (request) => this.sendAction(request, signal),
5366
+ readFile: opts?.readFile ?? (async () => {
5367
+ throw new MhBrowserError("NO_WORKSPACE", NO_WORKSPACE_REASON);
5368
+ })
5369
+ }, call);
5370
+ await this.enforceCap(owner);
5371
+ return sent;
4271
5372
  });
4272
5373
  }
4273
5374
  /**
4274
5375
  * Not queued: it opens no tab, and a long body must not hold up the
4275
5376
  * Session's other calls. `root` is the directory the session may write.
4276
5377
  */
4277
- async download(owner, url, root, signal) {
5378
+ async download(_owner, url, root, signal) {
4278
5379
  await this.ready;
4279
- this.refuseIfPerson(owner);
4280
- const linked = this.control.link(owner, signal);
4281
- try {
4282
- return await this.saveResponse(url, root, linked.signal);
4283
- } catch (error) {
4284
- if (this.control.of(owner) === "person") throw new MhBrowserError("TAKEN_OVER", MAY_HAVE_TAKEN_EFFECT);
4285
- throw error;
4286
- } finally {
4287
- linked.release();
4288
- }
5380
+ return this.saveResponse(url, root, signal);
4289
5381
  }
4290
5382
  controlOf(owner) {
4291
5383
  return this.control.of(owner);
@@ -4376,9 +5468,185 @@ let MhBrowserService = (() => {
4376
5468
  urls: this.presenceUrls(owner, taken.tabs)
4377
5469
  };
4378
5470
  }
4379
- async openOne(owner, url, waitFor, signal) {
5471
+ /**
5472
+ * Read one address. A known origin is tried on the request channel first.
5473
+ * A readable Agent View found on a tab this call opened is returned without
5474
+ * the tab, and that tab is not listed as closed.
5475
+ */
5476
+ async openAddress(owner, item, signal) {
5477
+ const wantsTab = owner === "person" || item.snapshot === true || item.waitFor !== void 0;
5478
+ if (!wantsTab && this.knownOrigin(item.url)) {
5479
+ const via = await this.readKnown(owner, item.url, signal);
5480
+ if (via === STOPPED) return {
5481
+ url: item.url,
5482
+ reason: WAITING_STOPPED
5483
+ };
5484
+ if (via !== void 0) return via;
5485
+ }
5486
+ const opened = await this.openOne(owner, item.url, item.waitFor, signal, item.snapshot === true);
5487
+ this.keepAgentView(opened);
5488
+ if (!wantsTab && opened.fresh && this.closable(owner, opened.live) && "representation" in opened.view && opened.view.representation.kind === "agent-view") {
5489
+ await this.windows.closeTab(opened.live);
5490
+ const view = { ...opened.view };
5491
+ delete view.tab;
5492
+ return view;
5493
+ }
5494
+ return opened.view;
5495
+ }
5496
+ /** A tab this call opened can be closed again unless the person took it or it holds input. */
5497
+ closable(owner, live) {
5498
+ return this.control.ofTab(owner, live.id) !== "person" && !live.occupied;
5499
+ }
5500
+ knownOrigin(url) {
5501
+ try {
5502
+ return this.agentViewOrigins.has(new URL(url).origin);
5503
+ } catch {
5504
+ return false;
5505
+ }
5506
+ }
5507
+ rememberOrigin(url) {
5508
+ try {
5509
+ this.agentViewOrigins.add(new URL(url).origin);
5510
+ } catch {}
5511
+ }
5512
+ /** A readable Agent View puts its origin on the list and keeps the document for the next read. */
5513
+ keepAgentView(opened) {
5514
+ if (!("representation" in opened.view)) return;
5515
+ const representation = opened.view.representation;
5516
+ if (!(representation.kind === "agent-view" || representation.kind === "snapshot" && representation.agentView !== void 0)) return;
5517
+ this.rememberOrigin(opened.view.url);
5518
+ const shown = this.shown.get(opened.live.id);
5519
+ if (shown?.kind === "agent-view") this.documents.set(canonical(shown.pageUrl), shown);
5520
+ }
5521
+ /**
5522
+ * Read a known origin without a tab. `undefined` means this page has to be
5523
+ * opened in a tab: nothing declared, the declaration could not be read, or
5524
+ * the request failed. The origin stays on the list either way. A stop
5525
+ * ends the read without falling back to a tab.
5526
+ */
5527
+ async readKnown(owner, url, signal) {
5528
+ const key = canonical(url);
5529
+ const previous = this.documents.get(key);
5530
+ const servedAtPage = previous?.kind === "agent-view" && previous.href !== void 0 && canonical(previous.href) === canonical(previous.pageUrl);
5531
+ let response;
5532
+ try {
5533
+ const got = await this.fetchSameOrigin(url, PAGE_ACCEPT, servedAtPage ? previous.etag : void 0, signal);
5534
+ if (signal?.aborted === true) return STOPPED;
5535
+ if (got === void 0) return void 0;
5536
+ response = got;
5537
+ } catch {
5538
+ return signal?.aborted === true ? STOPPED : void 0;
5539
+ }
5540
+ if (response.status === 304 && previous?.kind === "agent-view") return this.pageOfShown(owner, previous);
5541
+ const text = new TextDecoder().decode(response.body);
5542
+ const pageUrl = canonical(response.finalUrl === "" ? url : response.finalUrl);
5543
+ const cached = this.documents.get(pageUrl);
5544
+ const shown = await loadShown({
5545
+ pageUrl,
5546
+ ...optionalFragment(response.finalUrl === "" ? url : response.finalUrl),
5547
+ snapshot: "",
5548
+ main: {
5549
+ status: response.status,
5550
+ ...response.mediaType !== void 0 ? { mediaType: response.mediaType } : {},
5551
+ finalUrl: response.finalUrl === "" ? url : response.finalUrl,
5552
+ ...response.link !== void 0 ? { link: response.link } : {},
5553
+ ...response.etag !== void 0 ? { etag: response.etag } : {},
5554
+ body: text
5555
+ },
5556
+ domLinks: linksInHtml(text, response.finalUrl === "" ? url : response.finalUrl),
5557
+ ...cached !== void 0 ? { previous: cached } : {},
5558
+ relocate: false,
5559
+ fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch, signal)
5560
+ });
5561
+ if (stopped(signal)) return STOPPED;
5562
+ if (shown.kind !== "agent-view") return void 0;
5563
+ this.documents.set(pageUrl, shown);
5564
+ this.rememberOrigin(pageUrl);
5565
+ return this.pageOfShown(owner, shown);
5566
+ }
5567
+ /** One page from a document, with no tab. */
5568
+ pageOfShown(owner, shown) {
5569
+ const view = {
5570
+ url: shown.pageUrl,
5571
+ title: "",
5572
+ observedAt: (/* @__PURE__ */ new Date()).toISOString(),
5573
+ representation: shown.kind === "agent-view" ? {
5574
+ kind: "agent-view",
5575
+ text: shown.text,
5576
+ ...shown.version !== void 0 ? { version: shown.version } : {},
5577
+ ...shown.href !== void 0 ? { href: shown.href } : {}
5578
+ } : {
5579
+ kind: "snapshot",
5580
+ text: shown.snapshot,
5581
+ ...shown.agentViewError !== void 0 ? { agentViewError: shown.agentViewError } : {}
5582
+ },
5583
+ inPresence: this.book.isShown(owner, canonical(shown.pageUrl))
5584
+ };
5585
+ if (shown.fragment !== void 0) view.fragment = shown.fragment;
5586
+ if (shown.status !== void 0) view.status = shown.status;
5587
+ if (shown.mediaType !== void 0) view.mediaType = shown.mediaType;
5588
+ if (shown.body !== void 0) view.body = shown.body;
5589
+ return view;
5590
+ }
5591
+ /**
5592
+ * GET that follows same-origin redirects only. `undefined` when it cannot.
5593
+ * A body that is neither text nor an Agent View is not read: the page then
5594
+ * goes to a tab, which loads it anyway.
5595
+ */
5596
+ async fetchSameOrigin(url, accept, ifNoneMatch, signal) {
5597
+ const context = this.context;
5598
+ if (context === void 0) return void 0;
5599
+ let current = url;
5600
+ let origin;
5601
+ try {
5602
+ origin = new URL(url).origin;
5603
+ } catch {
5604
+ return;
5605
+ }
5606
+ for (let hop = 0; hop < 10; hop += 1) {
5607
+ const headers = { accept };
5608
+ if (ifNoneMatch !== void 0 && hop === 0) headers["if-none-match"] = ifNoneMatch;
5609
+ const response = await context.request.get(current, {
5610
+ headers,
5611
+ maxRedirects: 0,
5612
+ failOnStatusCode: false,
5613
+ ...signal !== void 0 ? { signal } : {}
5614
+ });
5615
+ const status = response.status();
5616
+ const location = response.headers().location;
5617
+ if (status >= 300 && status < 400 && location !== void 0 && location !== "") {
5618
+ let next;
5619
+ try {
5620
+ next = new URL(location, current);
5621
+ } catch {
5622
+ return;
5623
+ }
5624
+ if (next.origin !== origin) return void 0;
5625
+ current = next.href;
5626
+ continue;
5627
+ }
5628
+ const headerMap = response.headers();
5629
+ const mediaType = headerMap["content-type"];
5630
+ const body = {
5631
+ kind: "response",
5632
+ finalUrl: current,
5633
+ status,
5634
+ body: readsAsText(mediaType) ? new Uint8Array(await response.body()) : /* @__PURE__ */ new Uint8Array()
5635
+ };
5636
+ const etag = headerMap.etag;
5637
+ const link = headerMap.link;
5638
+ if (mediaType !== void 0) body.mediaType = mediaType;
5639
+ if (etag !== void 0) body.etag = etag;
5640
+ if (link !== void 0) body.link = link;
5641
+ return body;
5642
+ }
5643
+ }
5644
+ async openOne(owner, url, waitFor, signal, snapshot = false) {
4380
5645
  assertWaitFor(waitFor);
4381
- const live = this.idleTabOn(owner, url) ?? await this.windows.openBlank(owner);
5646
+ const reused = this.idleTabOn(owner, url);
5647
+ const live = reused ?? await this.windows.openBlank(owner);
5648
+ const fresh = reused === void 0;
5649
+ const linked = this.control.link(owner, signal, live.id);
4382
5650
  const nextDialog = this.windows.nextDialog(live);
4383
5651
  try {
4384
5652
  let seenDialog;
@@ -4388,7 +5656,7 @@ let MhBrowserService = (() => {
4388
5656
  });
4389
5657
  let navigationError;
4390
5658
  let response = null;
4391
- const raced = this.goto(live.page, url, signal).then((value) => {
5659
+ const raced = this.goto(live.page, url, linked.signal).then((value) => {
4392
5660
  response = value;
4393
5661
  return "navigated";
4394
5662
  }, (error) => {
@@ -4402,14 +5670,22 @@ let MhBrowserService = (() => {
4402
5670
  const view = await this.pageFromShown(live, this.shown.get(live.id));
4403
5671
  if (view.url === "" || view.url === "about:blank") view.url = canonical(url);
4404
5672
  this.applyDocumentType(live, view);
4405
- return this.attachDownload(live, view, true);
5673
+ return {
5674
+ view: await this.attachDownload(live, view, true),
5675
+ fresh,
5676
+ live
5677
+ };
4406
5678
  }
4407
5679
  throw navigationError;
4408
5680
  }
4409
- if (signal?.aborted) return {
4410
- tab: live.id,
4411
- url,
4412
- reason: WAITING_STOPPED
5681
+ if (linked.signal.aborted) return {
5682
+ view: {
5683
+ tab: live.id,
5684
+ url,
5685
+ reason: WAITING_STOPPED
5686
+ },
5687
+ fresh,
5688
+ live
4413
5689
  };
4414
5690
  const current = live.page.url();
4415
5691
  const address = current === "about:blank" ? url : current;
@@ -4425,11 +5701,11 @@ let MhBrowserService = (() => {
4425
5701
  load: false,
4426
5702
  pendingRequests: 0,
4427
5703
  dialog
4428
- } : await waitForLoad(live.page, signal);
5704
+ } : await waitForLoad(live.page, linked.signal);
4429
5705
  if (load.dialog !== void 0) this.windows.holdDialog(live);
4430
5706
  let waited;
4431
5707
  if (waitFor !== void 0 && load.dialog === void 0) {
4432
- const text = await waitForText(live.page, waitFor, signal);
5708
+ const text = await waitForText(live.page, waitFor, linked.signal);
4433
5709
  waited = {
4434
5710
  met: text.met,
4435
5711
  seconds: text.seconds
@@ -4446,6 +5722,7 @@ let MhBrowserService = (() => {
4446
5722
  }) : await this.discover(live, address, response);
4447
5723
  this.shown.set(live.id, shown);
4448
5724
  const view = await this.pageFromShown(live, shown, {
5725
+ ...snapshot ? { surface: "snapshot" } : {},
4449
5726
  ...load.load ? {} : { incomplete: {
4450
5727
  load: load.load,
4451
5728
  pendingRequests: load.pendingRequests
@@ -4453,26 +5730,51 @@ let MhBrowserService = (() => {
4453
5730
  ...load.dialog !== void 0 ? { dialog: load.dialog } : {},
4454
5731
  ...waited !== void 0 ? { waited } : {}
4455
5732
  });
4456
- return this.attachDownload(live, view, false);
5733
+ return {
5734
+ view: await this.attachDownload(live, view, false),
5735
+ fresh,
5736
+ live
5737
+ };
4457
5738
  } catch (error) {
4458
5739
  nextDialog.cancel();
4459
- if (this.control.of(owner) === "person") throw await this.takenOver(live);
4460
- if (signal?.aborted || error instanceof MhBrowserError && error.code === "INTERRUPTED") return {
4461
- tab: live.id,
4462
- url,
4463
- reason: WAITING_STOPPED
5740
+ if (this.control.ofTab(owner, live.id) === "person") throw await this.takenOver(live);
5741
+ if (linked.signal.aborted || error instanceof MhBrowserError && error.code === "INTERRUPTED") return {
5742
+ view: {
5743
+ tab: live.id,
5744
+ url,
5745
+ reason: WAITING_STOPPED
5746
+ },
5747
+ fresh,
5748
+ live
4464
5749
  };
4465
5750
  return {
4466
- tab: live.id,
4467
- url,
4468
- reason: error instanceof Error ? error.message : String(error)
5751
+ view: {
5752
+ tab: live.id,
5753
+ url,
5754
+ reason: error instanceof Error ? error.message : String(error)
5755
+ },
5756
+ fresh,
5757
+ live
4469
5758
  };
5759
+ } finally {
5760
+ linked.release();
4470
5761
  }
4471
5762
  }
4472
- async perform(live, action, signal) {
4473
- if (action.kind === "look") return { navigated: false };
4474
- if (action.kind === "wait") {
4475
- const waitFor = waitFrom(action);
5763
+ async perform(live, op, signal) {
5764
+ if (op.kind === "snapshot") return { navigated: false };
5765
+ if (op.kind === "find") return {
5766
+ navigated: false,
5767
+ summary: findInSnapshot(await this.aria(live), op)
5768
+ };
5769
+ if (op.kind === "evaluate") {
5770
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
5771
+ const target = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
5772
+ return this.evaluateOn(live, op, target, signal);
5773
+ }
5774
+ if (op.kind === "take_screenshot") return this.shoot(live, op, signal);
5775
+ if (op.kind === "file_upload") return this.uploadHeld(live, op, signal);
5776
+ if (op.kind === "wait_for") {
5777
+ const waitFor = waitFrom(op);
4476
5778
  assertWaitFor(waitFor);
4477
5779
  const text = await waitForText(live.page, waitFor, signal);
4478
5780
  if (text.dialog !== void 0) this.windows.holdDialog(live);
@@ -4485,11 +5787,11 @@ let MhBrowserService = (() => {
4485
5787
  ...text.dialog !== void 0 ? { dialog: text.dialog } : {}
4486
5788
  };
4487
5789
  }
4488
- if (action.kind === "dialog") {
5790
+ if (op.kind === "handle_dialog") {
4489
5791
  const dialog = live.pendingDialog;
4490
5792
  if (dialog === void 0) throw new MhBrowserError("INVALID_ARGS", "this page has no dialog");
4491
5793
  const before = live.page.url();
4492
- if (action.accept) await (action.promptText !== void 0 ? dialog.accept(action.promptText) : dialog.accept());
5794
+ if (op.accept) await (op.promptText !== void 0 ? dialog.accept(op.promptText) : dialog.accept());
4493
5795
  else await dialog.dismiss();
4494
5796
  this.windows.clearDialog(live);
4495
5797
  const navigated = live.page.url() !== before;
@@ -4501,9 +5803,10 @@ let MhBrowserService = (() => {
4501
5803
  const requests = trackActionRequests(live.page);
4502
5804
  const nextDialog = this.windows.nextDialog(live);
4503
5805
  const chooser = watchFileChooser(live.page);
5806
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
4504
5807
  this.windows.beginDispatch(live.page);
4505
5808
  try {
4506
- const finished = runAction(live.page, action, signal).then((navigated) => ({
5809
+ const finished = runAction(live.page, op, snapshot, signal).then((navigated) => ({
4507
5810
  kind: "done",
4508
5811
  navigated
4509
5812
  }));
@@ -4511,9 +5814,9 @@ let MhBrowserService = (() => {
4511
5814
  kind: "dialog",
4512
5815
  dialog
4513
5816
  }));
4514
- const chose = chooser.opened.then((multiple) => ({
5817
+ const chose = chooser.opened.then((opened) => ({
4515
5818
  kind: "chooser",
4516
- multiple
5819
+ opened
4517
5820
  }));
4518
5821
  const winner = await Promise.race([
4519
5822
  finished,
@@ -4537,13 +5840,13 @@ let MhBrowserService = (() => {
4537
5840
  finished.catch(() => void 0);
4538
5841
  return {
4539
5842
  navigated: false,
4540
- dialog: this.chooserHeld(live, winner.multiple)
5843
+ dialog: this.chooserHeld(live, winner.opened)
4541
5844
  };
4542
5845
  }
4543
- const settledLoad = await Promise.race([settleAction(live.page, winner.navigated, requests, signal), chooser.opened.then((multiple) => ({
5846
+ const settledLoad = await Promise.race([settleAction(live.page, winner.navigated, requests, signal), chooser.opened.then((opened) => ({
4544
5847
  load: true,
4545
5848
  pendingRequests: 0,
4546
- dialog: this.chooserHeld(live, multiple)
5849
+ dialog: this.chooserHeld(live, opened)
4547
5850
  }))]);
4548
5851
  return {
4549
5852
  navigated: winner.navigated,
@@ -4555,12 +5858,121 @@ let MhBrowserService = (() => {
4555
5858
  chooser.stop();
4556
5859
  }
4557
5860
  }
5861
+ /**
5862
+ * `browser_evaluate`. A dialog the function opens is held like any other; a
5863
+ * stop or a takeover ends the wait and still reports the writes already sent.
5864
+ */
5865
+ async evaluateOn(live, op, target, signal) {
5866
+ const nextDialog = this.windows.nextDialog(live);
5867
+ let navigated = false;
5868
+ const onNav = (frame) => {
5869
+ if (frame.parentFrame() === null) navigated = true;
5870
+ };
5871
+ live.page.on("framenavigated", onNav);
5872
+ try {
5873
+ const evaluated = await evaluateMarked(live.page, op.function, target, op.callId, signal, nextDialog.dialog);
5874
+ const writes = formatWrites(evaluated.writes);
5875
+ const counted = evaluated.writes.length === 0 ? {} : { writes: evaluated.writes };
5876
+ if (evaluated.kind === "stopped") {
5877
+ const note = writes === "" ? "" : `\n${writes}`;
5878
+ if (this.control.ofTab(live.owner, live.id) === "person") throw await this.takenOver(live, note);
5879
+ throw new MhBrowserError(INTERRUPTED, `${WAITING_STOPPED}${note}`);
5880
+ }
5881
+ if (evaluated.kind === "dialog") {
5882
+ this.windows.holdDialog(live, evaluated.dialog);
5883
+ return {
5884
+ navigated: false,
5885
+ dialog: {
5886
+ type: evaluated.dialog.type(),
5887
+ message: evaluated.dialog.message()
5888
+ },
5889
+ summary: "",
5890
+ detail: writes,
5891
+ ...counted
5892
+ };
5893
+ }
5894
+ if (evaluated.kind === "navigated") return {
5895
+ navigated: true,
5896
+ summary: NAVIGATED_DURING_EVALUATE,
5897
+ detail: writes,
5898
+ ...counted
5899
+ };
5900
+ return {
5901
+ navigated,
5902
+ summary: evaluated.value,
5903
+ detail: writes,
5904
+ ...counted
5905
+ };
5906
+ } finally {
5907
+ live.page.off("framenavigated", onNav);
5908
+ nextDialog.cancel();
5909
+ }
5910
+ }
5911
+ async uploadHeld(live, op, signal) {
5912
+ const chooser = live.fileChooser;
5913
+ if (chooser === void 0) throw new MhBrowserError("INVALID_ARGS", "No file chooser visible");
5914
+ const paths = op.paths;
5915
+ if (paths === void 0 || paths.length === 0) {
5916
+ live.fileChooser = void 0;
5917
+ return { navigated: false };
5918
+ }
5919
+ const requests = trackActionRequests(live.page);
5920
+ let navigated = false;
5921
+ const onNav = (frame) => {
5922
+ if (frame.parentFrame() === null) navigated = true;
5923
+ };
5924
+ live.page.on("framenavigated", onNav);
5925
+ this.windows.beginDispatch(live.page);
5926
+ try {
5927
+ await chooser.setFiles(paths, signal === void 0 ? {} : { signal });
5928
+ live.fileChooser = void 0;
5929
+ await settleAction(live.page, navigated, requests, signal);
5930
+ return { navigated };
5931
+ } finally {
5932
+ live.page.off("framenavigated", onNav);
5933
+ this.windows.endDispatch(live.page);
5934
+ requests.stop();
5935
+ }
5936
+ }
5937
+ async shoot(live, op, signal) {
5938
+ if (op.fullPage === true && op.target !== void 0) throw new MhBrowserError("INVALID_ARGS", "fullPage cannot be used with element screenshots.");
5939
+ const type = op.type ?? "png";
5940
+ const snapshot = this.shown.get(live.id)?.snapshot ?? "";
5941
+ const locator = op.target === void 0 ? void 0 : await locate(live.page, op.target, snapshot);
5942
+ const options = {
5943
+ type,
5944
+ ...type === "jpeg" ? { quality: 90 } : {},
5945
+ ...op.scale !== void 0 ? { scale: op.scale } : {},
5946
+ ...op.fullPage === true ? { fullPage: true } : {},
5947
+ ...signal !== void 0 ? { signal } : {}
5948
+ };
5949
+ const bytes = locator === void 0 ? await live.page.screenshot(options) : await locator.screenshot(options);
5950
+ return {
5951
+ navigated: false,
5952
+ shot: {
5953
+ mediaType: `image/${type}`,
5954
+ bytes: new Uint8Array(bytes)
5955
+ }
5956
+ };
5957
+ }
5958
+ /** A snapshot limited to a target, a depth, or boxes. Refs still resolve against the full one. */
5959
+ async scopedSnapshot(tab, op) {
5960
+ const options = {
5961
+ mode: "ai",
5962
+ ...op.depth !== void 0 ? { depth: op.depth } : {},
5963
+ ...op.boxes === true ? { boxes: true } : {}
5964
+ };
5965
+ const snapshot = this.shown.get(tab.id)?.snapshot ?? "";
5966
+ if (op.target !== void 0) return (await locate(tab.page, op.target, snapshot)).ariaSnapshot(options);
5967
+ return tab.page.ariaSnapshot(options);
5968
+ }
4558
5969
  /** A file chooser the action opened. The tab stays in use until it navigates. */
4559
- chooserHeld(live, multiple) {
5970
+ chooserHeld(live, chooser) {
5971
+ live.fileChooser = chooser;
4560
5972
  this.windows.holdInput(live);
4561
5973
  return {
4562
5974
  type: "filechooser",
4563
- message: multiple ? "choose files (several allowed) with upload" : "choose one file with upload"
5975
+ message: chooser.isMultiple() ? "choose files (several allowed) with browser_file_upload" : "choose one file with browser_file_upload"
4564
5976
  };
4565
5977
  }
4566
5978
  async refreshSnapshot(tab) {
@@ -4616,10 +6028,11 @@ let MhBrowserService = (() => {
4616
6028
  fetch: (target, accept, ifNoneMatch) => this.fetchForRead(target, accept, ifNoneMatch)
4617
6029
  });
4618
6030
  }
4619
- fetchForRead(url, accept, ifNoneMatch) {
6031
+ fetchForRead(url, accept, ifNoneMatch, signal) {
4620
6032
  return this.fetch(url, {
4621
6033
  accept,
4622
- ...ifNoneMatch !== void 0 ? { ifNoneMatch } : {}
6034
+ ...ifNoneMatch !== void 0 ? { ifNoneMatch } : {},
6035
+ ...signal !== void 0 ? { signal } : {}
4623
6036
  });
4624
6037
  }
4625
6038
  async aria(tab) {
@@ -4663,13 +6076,36 @@ let MhBrowserService = (() => {
4663
6076
  if (extra?.waited !== void 0) view.waited = extra.waited;
4664
6077
  return view;
4665
6078
  }
4666
- /** Read one presence page. A tab opened only for this read is closed again. */
6079
+ /**
6080
+ * Read one presence page. A known origin is read on the request channel,
6081
+ * with If-None-Match when the document was read before. Anything else uses
6082
+ * a tab already on the address, or opens one and closes it again.
6083
+ */
4667
6084
  async observePresence(owner, url, signal) {
6085
+ if (this.knownOrigin(url)) {
6086
+ const via = await this.readKnown(owner, url, signal);
6087
+ if (via === STOPPED) return observationOf(url, {
6088
+ url,
6089
+ reason: WAITING_STOPPED
6090
+ });
6091
+ if (via !== void 0) return observationOf(url, via);
6092
+ }
4668
6093
  const existing = this.tabOn(owner, url);
4669
- const view = existing !== void 0 ? await this.refreshView(existing) : await this.openOne(owner, url, void 0, signal);
4670
- if (existing === void 0 && "tab" in view && view.tab !== void 0) {
4671
- const opened = this.windows.get(owner, view.tab);
4672
- if (opened !== void 0) await this.windows.closeTab(opened, `${opened.id} (idle)`);
6094
+ if (existing !== void 0) return observationOf(url, await this.refreshView(existing));
6095
+ const opened = await this.openOne(owner, url, void 0, signal, false);
6096
+ this.keepAgentView(opened);
6097
+ const view = opened.view;
6098
+ if (opened.fresh && "tab" in view && view.tab !== void 0) {
6099
+ const live = this.windows.get(owner, view.tab);
6100
+ if (live !== void 0 && this.closable(owner, live)) {
6101
+ const agent = "representation" in view && view.representation.kind === "agent-view";
6102
+ await this.windows.closeTab(live, agent ? void 0 : `${live.id} (idle)`);
6103
+ if (agent) {
6104
+ const shown = { ...view };
6105
+ delete shown.tab;
6106
+ return observationOf(url, shown);
6107
+ }
6108
+ }
4673
6109
  }
4674
6110
  return observationOf(url, view);
4675
6111
  }
@@ -4694,17 +6130,12 @@ let MhBrowserService = (() => {
4694
6130
  });
4695
6131
  this.shown.set(live.id, shown);
4696
6132
  this.windows.noteUrl(live, address);
4697
- return this.pageFromShown(live, shown);
4698
- }
4699
- async idleView(owner, url) {
4700
- const live = this.idleTabOn(owner, url);
4701
- if (live === void 0) return void 0;
4702
- return this.refreshView(live);
6133
+ return this.pageFromShown(live, shown);
4703
6134
  }
4704
- /** The most recently touched idle tab on this address. */
6135
+ /** The most recently touched idle tab on this address. A tab the person holds is left alone. */
4705
6136
  idleTabOn(owner, url) {
4706
6137
  const pageUrl = canonical(url);
4707
- const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl && !tab.occupied);
6138
+ const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((tab) => tab !== void 0 && canonical(tab.url) === pageUrl && !tab.occupied && this.control.ofTab(owner, tab.id) !== "person");
4708
6139
  tabs.sort((left, right) => right.touchedAt - left.touchedAt);
4709
6140
  return tabs[0];
4710
6141
  }
@@ -4713,10 +6144,15 @@ let MhBrowserService = (() => {
4713
6144
  if (owner === "person") return;
4714
6145
  await this.windows.enforceCap(owner);
4715
6146
  }
4716
- async navigateOwned(owner, tab, url, signal) {
4717
- const live = this.windows.get(owner, tab);
4718
- if (live === void 0) return unknownTab();
4719
- return this.navigateTab(live, url, signal);
6147
+ /** Other tabs on this address: idle ones reload, occupied or taken ones are marked. */
6148
+ async refreshIdle(owner, url, except, signal) {
6149
+ const pageUrl = canonical(url);
6150
+ const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((item) => item !== void 0 && canonical(item.url) === pageUrl);
6151
+ for (const item of tabs) {
6152
+ if (item.id === except) continue;
6153
+ if (item.occupied || this.control.ofTab(owner, item.id) === "person") this.windows.markMayHaveChanged(item);
6154
+ else await this.navigateTab(item, item.page.url(), signal);
6155
+ }
4720
6156
  }
4721
6157
  async navigateTab(live, url, signal) {
4722
6158
  try {
@@ -4751,18 +6187,6 @@ let MhBrowserService = (() => {
4751
6187
  };
4752
6188
  }
4753
6189
  }
4754
- async reloadSame(owner, url, tab, signal) {
4755
- const pageUrl = canonical(url);
4756
- const current = this.windows.get(owner, tab);
4757
- if (current === void 0) return unknownTab();
4758
- const tabs = this.windows.list(owner).map((info) => this.windows.get(owner, info.tab)).filter((item) => item !== void 0 && canonical(item.url) === pageUrl);
4759
- for (const item of tabs) {
4760
- if (item.id === current.id) continue;
4761
- if (item.occupied) this.windows.markMayHaveChanged(item);
4762
- else await this.navigateTab(item, item.page.url(), signal);
4763
- }
4764
- return this.navigateTab(current, current.page.url(), signal);
4765
- }
4766
6190
  async sendAction(request, signal) {
4767
6191
  const context = this.context;
4768
6192
  if (context === void 0) return { kind: "lost" };
@@ -4926,16 +6350,16 @@ let MhBrowserService = (() => {
4926
6350
  (browser?.process?.())?.kill("SIGKILL");
4927
6351
  this.context = void 0;
4928
6352
  }
4929
- refuseIfPerson(owner) {
4930
- if (this.control.of(owner) !== "person") return;
4931
- throw new MhBrowserError("PERSON_IN_CONTROL", "the person is using the browser; call browser_handover to wait until they hand it back");
6353
+ refuseIfPerson(owner, tab) {
6354
+ if (this.control.ofTab(owner, tab) !== "person") return;
6355
+ throw new MhBrowserError("PERSON_IN_CONTROL", "the person is using this tab; call browser_handover with this tab to wait until they hand it back, or work in another tab");
4932
6356
  }
4933
- async takenOver(live) {
6357
+ async takenOver(live, note = "") {
4934
6358
  try {
4935
6359
  const page = await this.pageFromShown(live, this.shown.get(live.id), { surface: "snapshot" });
4936
- return new MhBrowserError("TAKEN_OVER", MAY_HAVE_TAKEN_EFFECT, page);
6360
+ return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`, page);
4937
6361
  } catch {
4938
- return new MhBrowserError("TAKEN_OVER", MAY_HAVE_TAKEN_EFFECT);
6362
+ return new MhBrowserError("TAKEN_OVER", `${MAY_HAVE_TAKEN_EFFECT}${note}`);
4939
6363
  }
4940
6364
  }
4941
6365
  presenceUrls(owner, tabs) {
@@ -5116,7 +6540,7 @@ function watchFileChooser(page) {
5116
6540
  let onChooser;
5117
6541
  return {
5118
6542
  opened: new Promise((resolve) => {
5119
- onChooser = (chooser) => resolve(chooser.isMultiple());
6543
+ onChooser = (chooser) => resolve(chooser);
5120
6544
  page.on("filechooser", onChooser);
5121
6545
  }),
5122
6546
  stop: () => {
@@ -5124,15 +6548,6 @@ function watchFileChooser(page) {
5124
6548
  }
5125
6549
  };
5126
6550
  }
5127
- function missingRef(snapshot, action) {
5128
- return refsOf(action).find((ref) => !snapshot.includes(`[ref=${ref}]`));
5129
- }
5130
- function refsOf(action) {
5131
- if (action.kind === "click" || action.kind === "select" || action.kind === "upload") return [action.ref];
5132
- if (action.kind === "press") return action.ref === void 0 ? [] : [action.ref];
5133
- if (action.kind === "fill") return action.fields.map((field) => field.ref);
5134
- return [];
5135
- }
5136
6551
  function snapshotSurface(current) {
5137
6552
  const agentView = current.kind === "agent-view" ? current.version !== void 0 ? { version: current.version } : {} : void 0;
5138
6553
  return {
@@ -5142,478 +6557,15 @@ function snapshotSurface(current) {
5142
6557
  ...agentView !== void 0 ? { agentView } : {}
5143
6558
  };
5144
6559
  }
5145
- //#endregion
5146
- //#region lib/types/tools/act.js
5147
- /** The saved screenshot as a plain value; `render` re-brands it into the block's reference. */
5148
- const IMAGE_VALUE = {
5149
- type: "object",
5150
- additionalProperties: false,
5151
- properties: {
5152
- attachmentId: {
5153
- type: "string",
5154
- required: true
5155
- },
5156
- mediaType: {
5157
- type: "string",
5158
- enum: [
5159
- "image/png",
5160
- "image/jpeg",
5161
- "image/webp",
5162
- "image/gif"
5163
- ],
5164
- required: true
5165
- },
5166
- bytes: {
5167
- type: "integer",
5168
- required: true
5169
- },
5170
- width: {
5171
- type: "integer",
5172
- required: true
5173
- },
5174
- height: {
5175
- type: "integer",
5176
- required: true
5177
- },
5178
- name: { type: "string" },
5179
- originalDimensions: {
5180
- type: "object",
5181
- additionalProperties: false,
5182
- properties: {
5183
- width: {
5184
- type: "integer",
5185
- required: true
5186
- },
5187
- height: {
5188
- type: "integer",
5189
- required: true
5190
- }
5191
- }
5192
- }
5193
- }
5194
- };
5195
- function imageValue(ref) {
5196
- return {
5197
- attachmentId: ref.attachmentId,
5198
- mediaType: ref.mediaType,
5199
- bytes: ref.bytes,
5200
- width: ref.width,
5201
- height: ref.height,
5202
- ...ref.name === void 0 ? {} : { name: ref.name },
5203
- ...ref.originalDimensions === void 0 ? {} : { originalDimensions: { ...ref.originalDimensions } }
5204
- };
5205
- }
5206
- /** `dsh-llm` types its block against the hoisted `dsh-attachment`, so the id is re-branded through that type. */
5207
- function imageRef(value) {
5208
- return {
5209
- attachmentId: value.attachmentId,
5210
- mediaType: value.mediaType,
5211
- bytes: value.bytes,
5212
- width: value.width,
5213
- height: value.height,
5214
- ...value.name === void 0 ? {} : { name: value.name },
5215
- ...value.originalDimensions === void 0 ? {} : { originalDimensions: { ...value.originalDimensions } }
5216
- };
5217
- }
5218
- const ACT = [
5219
- "Do one thing on a tab's human page and return its aria snapshot.",
5220
- "Use it for a page without an Agent View, or to work the human page of one; to read a page use browser_open, and to call a declared action use browser_invoke.",
5221
- "A page with an Agent View still returns a snapshot (agent-view=\"available\"); use look first when you need refs for it.",
5222
- "A tab is in use while it has unsubmitted input, an open dialog, or a handover; an idle tab may be closed (UNKNOWN_TAB), and browser_open of its address opens it again.",
5223
- "Do not fill or submit a login form or a form that issues credentials; call browser_handover."
5224
- ].join(" ");
5225
- const TAB$1 = "Tab id copied from the latest browser result. An unknown id fails with UNKNOWN_TAB and returns the current tabs list.";
5226
- const ACTION = [
5227
- "The one action to perform.",
5228
- "ref is an element ref copied from this tab's latest snapshot, such as e3; one that is not in that snapshot fails with STALE_REF and returns the current snapshot.",
5229
- "element is an optional short label for the person reading the log; it does not locate anything.",
5230
- "press without a ref goes to the focused element."
5231
- ].join(" ");
5232
- const REF = "Element ref, such as e3.";
5233
- const ELEMENT = "Label for the person.";
5234
- function browserActTool(ctx) {
5235
- const reader = logReader(ctx);
5236
- return defineTool({
5237
- name: "browser_act",
5238
- description: ACT,
5239
- parameters: {
5240
- tab: {
5241
- type: "string",
5242
- required: true,
5243
- description: TAB$1
5244
- },
5245
- action: {
5246
- required: true,
5247
- description: ACTION,
5248
- oneOf: [
5249
- {
5250
- type: "object",
5251
- additionalProperties: false,
5252
- description: "Click an element.",
5253
- properties: {
5254
- kind: {
5255
- type: "string",
5256
- const: "click",
5257
- required: true,
5258
- description: "click"
5259
- },
5260
- ref: {
5261
- type: "string",
5262
- required: true,
5263
- description: REF
5264
- },
5265
- element: {
5266
- type: "string",
5267
- description: ELEMENT
5268
- },
5269
- double: {
5270
- type: "boolean",
5271
- description: "Double-click instead of click."
5272
- },
5273
- button: {
5274
- type: "string",
5275
- enum: [
5276
- "left",
5277
- "right",
5278
- "middle"
5279
- ],
5280
- description: "Defaults to left."
5281
- },
5282
- modifiers: {
5283
- type: "array",
5284
- description: "Modifier keys held during the click.",
5285
- items: {
5286
- type: "string",
5287
- enum: [
5288
- "Alt",
5289
- "Control",
5290
- "Meta",
5291
- "Shift"
5292
- ]
5293
- }
5294
- }
5295
- }
5296
- },
5297
- {
5298
- type: "object",
5299
- additionalProperties: false,
5300
- description: "Choose options on a list element; this does not submit.",
5301
- properties: {
5302
- kind: {
5303
- type: "string",
5304
- const: "select",
5305
- required: true,
5306
- description: "select"
5307
- },
5308
- ref: {
5309
- type: "string",
5310
- required: true,
5311
- description: REF
5312
- },
5313
- element: {
5314
- type: "string",
5315
- description: ELEMENT
5316
- },
5317
- values: {
5318
- type: "array",
5319
- required: true,
5320
- description: "Option values copied from the control; at least one.",
5321
- items: { type: "string" }
5322
- }
5323
- }
5324
- },
5325
- {
5326
- type: "object",
5327
- additionalProperties: false,
5328
- description: "Press a key.",
5329
- properties: {
5330
- kind: {
5331
- type: "string",
5332
- const: "press",
5333
- required: true,
5334
- description: "press"
5335
- },
5336
- key: {
5337
- type: "string",
5338
- required: true,
5339
- description: "Key name, such as Enter or ArrowDown."
5340
- },
5341
- ref: {
5342
- type: "string",
5343
- description: REF
5344
- },
5345
- element: {
5346
- type: "string",
5347
- description: ELEMENT
5348
- }
5349
- }
5350
- },
5351
- {
5352
- type: "object",
5353
- additionalProperties: false,
5354
- description: "Fill fields; this does not submit.",
5355
- properties: {
5356
- kind: {
5357
- type: "string",
5358
- const: "fill",
5359
- required: true,
5360
- description: "fill"
5361
- },
5362
- fields: {
5363
- type: "array",
5364
- required: true,
5365
- description: "Fields to write in one step; at least one.",
5366
- items: {
5367
- type: "object",
5368
- additionalProperties: false,
5369
- properties: {
5370
- ref: {
5371
- type: "string",
5372
- required: true,
5373
- description: REF
5374
- },
5375
- element: {
5376
- type: "string",
5377
- description: ELEMENT
5378
- },
5379
- value: {
5380
- type: "string",
5381
- required: true,
5382
- description: "Text to write; for a checkbox, true or false."
5383
- }
5384
- }
5385
- }
5386
- }
5387
- }
5388
- },
5389
- {
5390
- type: "object",
5391
- additionalProperties: false,
5392
- description: "Set files on a file input.",
5393
- properties: {
5394
- kind: {
5395
- type: "string",
5396
- const: "upload",
5397
- required: true,
5398
- description: "upload"
5399
- },
5400
- ref: {
5401
- type: "string",
5402
- required: true,
5403
- description: REF
5404
- },
5405
- element: {
5406
- type: "string",
5407
- description: ELEMENT
5408
- },
5409
- files: {
5410
- type: "array",
5411
- required: true,
5412
- description: "Attachment ids shown in this session, or paths inside the session cwd; at least one.",
5413
- items: { type: "string" }
5414
- }
5415
- }
5416
- },
5417
- {
5418
- type: "object",
5419
- additionalProperties: false,
5420
- description: "Accept or dismiss the open dialog.",
5421
- properties: {
5422
- kind: {
5423
- type: "string",
5424
- const: "dialog",
5425
- required: true,
5426
- description: "dialog"
5427
- },
5428
- accept: {
5429
- type: "boolean",
5430
- required: true,
5431
- description: "True accepts, false dismisses."
5432
- },
5433
- promptText: {
5434
- type: "string",
5435
- description: "Text to enter when accepting a prompt."
5436
- }
5437
- }
5438
- },
5439
- {
5440
- type: "object",
5441
- additionalProperties: false,
5442
- description: "Go back one step in this tab's history.",
5443
- properties: { kind: {
5444
- type: "string",
5445
- const: "back",
5446
- required: true,
5447
- description: "back"
5448
- } }
5449
- },
5450
- {
5451
- type: "object",
5452
- additionalProperties: false,
5453
- description: "Wait for text to appear or disappear, or for time seconds, then return the snapshot.",
5454
- properties: {
5455
- kind: {
5456
- type: "string",
5457
- const: "wait",
5458
- required: true,
5459
- description: "wait"
5460
- },
5461
- text: {
5462
- type: "string",
5463
- description: "Text to wait for; the snapshot is returned even if it never appears."
5464
- },
5465
- textGone: {
5466
- type: "string",
5467
- description: "Text to wait until it disappears; with text set too, the first one wins."
5468
- },
5469
- time: {
5470
- type: "number",
5471
- description: "Seconds to wait, or the limit for text and textGone; capped at 30, default 30."
5472
- }
5473
- }
5474
- },
5475
- {
5476
- type: "object",
5477
- additionalProperties: false,
5478
- description: "Return the current snapshot without acting; use it to obtain refs.",
5479
- properties: { kind: {
5480
- type: "string",
5481
- const: "look",
5482
- required: true,
5483
- description: "look"
5484
- } }
5485
- }
5486
- ]
5487
- },
5488
- screenshot: {
5489
- type: "boolean",
5490
- description: "True adds a PNG of this tab after the page; none is taken while a dialog is open."
5491
- }
5492
- },
5493
- output: {
5494
- schema: {
5495
- type: "object",
5496
- additionalProperties: false,
5497
- properties: {
5498
- text: {
5499
- type: "string",
5500
- required: true
5501
- },
5502
- meta: {
5503
- type: "json",
5504
- required: true
5505
- },
5506
- image: IMAGE_VALUE
5507
- }
5508
- },
5509
- render: (_args, value) => {
5510
- const text = {
5511
- type: "text",
5512
- text: value.text
5513
- };
5514
- if (value.image === void 0) return [text];
5515
- return [text, {
5516
- type: "image",
5517
- attachment: imageRef(value.image)
5518
- }];
5519
- },
5520
- presentationMeta: (_args, value) => value.meta
5521
- },
5522
- isConcurrencySafe: () => false,
5523
- timeoutMs: 6e4,
5524
- presentCall: (args) => ({
5525
- card: "generic",
5526
- kind: "other",
5527
- title: `browser_act ${args.tab} ${args.action.kind}`
5528
- }),
5529
- presentResult: (args) => ({
5530
- card: "generic",
5531
- title: `browser_act ${args.tab} ${args.action.kind}`
5532
- }),
5533
- async execute(args, exec) {
5534
- const session = sessionFor(ctx, exec);
5535
- ctx.mhBrowser.noteWorkspace(session.id, writeTarget(ctx, session));
5536
- try {
5537
- const action = await toAction(args.action, (files) => uploadPaths(ctx, reader.attachments(session.session), session.cwd, files));
5538
- const view = await ctx.mhBrowser.act(session.id, args.tab, action, exec.signal);
5539
- const image = args.screenshot === true ? await screenshot(ctx, session.id, args.tab, exec.signal) : void 0;
5540
- const closed = closedNotes(ctx, reader, session);
5541
- const tabs = ctx.mhBrowser.tabs(session.id);
5542
- return {
5543
- text: renderOutcome([view], tabs, closed, image === void 0 ? void 0 : image.attachmentId),
5544
- meta: JSON.parse(JSON.stringify({
5545
- ...readingsOf([view], action.kind),
5546
- closed
5547
- })),
5548
- ...image === void 0 ? {} : { image: imageValue(image) }
5549
- };
5550
- } catch (error) {
5551
- if (error instanceof MhBrowserError && error.code === "UNKNOWN_TAB") rethrow(new MhBrowserError(UNKNOWN_TAB, `${UNKNOWN_TAB_REASON} ${args.tab}`), renderOutcome([], ctx.mhBrowser.tabs(session.id), closedNotes(ctx, reader, session)));
5552
- if (error instanceof MhBrowserError && (error.code === "STALE_REF" || error.code === "TAKEN_OVER") && error.page !== void 0) rethrow(error, renderOutcome([error.page], ctx.mhBrowser.tabs(session.id), closedNotes(ctx, reader, session)));
5553
- rethrow(error);
5554
- }
5555
- }
5556
- });
5557
- }
5558
- /** The action already happened, so a screenshot that cannot be taken or saved is left out, not an error. */
5559
- async function screenshot(ctx, owner, tab, signal) {
5560
- const data = await ctx.mhBrowser.capture(owner, tab, signal);
5561
- if (data === void 0) return void 0;
5562
- try {
5563
- return await ctx.attachments.saveImage({
5564
- data,
5565
- mediaType: "image/png",
5566
- name: `${tab}.png`
5567
- });
5568
- } catch (error) {
5569
- ctx.logger.warn("saving the screenshot of %s failed: %o", tab, error);
5570
- return;
5571
- }
6560
+ /** Text and Agent View bodies are read on the request channel; anything else is left to a tab. */
6561
+ function readsAsText(mediaType) {
6562
+ if (mediaType === void 0) return true;
6563
+ const type = mediaType.split(";")[0]?.trim().toLowerCase() ?? "";
6564
+ return type.startsWith("text/") || type === "application/xhtml+xml";
5572
6565
  }
5573
- async function toAction(action, resolveFiles) {
5574
- if (action.kind === "click" && action.ref !== void 0) return {
5575
- kind: "click",
5576
- ref: action.ref,
5577
- ...action.double !== void 0 ? { double: action.double } : {},
5578
- ...action.button !== void 0 ? { button: action.button } : {},
5579
- ...action.modifiers !== void 0 ? { modifiers: [...action.modifiers] } : {}
5580
- };
5581
- if (action.kind === "select" && action.ref !== void 0 && action.values !== void 0) return {
5582
- kind: "select",
5583
- ref: action.ref,
5584
- values: [...action.values]
5585
- };
5586
- if (action.kind === "press" && action.key !== void 0) return {
5587
- kind: "press",
5588
- key: action.key,
5589
- ...action.ref !== void 0 ? { ref: action.ref } : {}
5590
- };
5591
- if (action.kind === "fill" && action.fields !== void 0) return {
5592
- kind: "fill",
5593
- fields: action.fields.map((field) => ({
5594
- ref: field.ref,
5595
- value: field.value
5596
- }))
5597
- };
5598
- if (action.kind === "upload" && action.ref !== void 0 && action.files !== void 0) return {
5599
- kind: "upload",
5600
- ref: action.ref,
5601
- files: await resolveFiles(action.files)
5602
- };
5603
- if (action.kind === "dialog" && action.accept !== void 0) return {
5604
- kind: "dialog",
5605
- accept: action.accept,
5606
- ...action.promptText !== void 0 ? { promptText: action.promptText } : {}
5607
- };
5608
- if (action.kind === "back") return { kind: "back" };
5609
- if (action.kind === "wait") return {
5610
- kind: "wait",
5611
- ...action.text !== void 0 ? { text: action.text } : {},
5612
- ...action.textGone !== void 0 ? { textGone: action.textGone } : {},
5613
- ...action.time !== void 0 ? { time: action.time } : {}
5614
- };
5615
- if (action.kind === "look") return { kind: "look" };
5616
- throw new Error(`unknown action ${action.kind}`);
6566
+ /** Read after an await: an earlier check does not narrow it. */
6567
+ function stopped(signal) {
6568
+ return signal?.aborted === true;
5617
6569
  }
5618
6570
  //#endregion
5619
6571
  //#region lib/types/tools/download.js
@@ -5667,8 +6619,8 @@ function browserDownloadTool(ctx) {
5667
6619
  ctx.mhBrowser.noteWorkspace(session.id, target);
5668
6620
  if ("denied" in target) throw new BrowserToolError(target.code, target.denied);
5669
6621
  const saved = await ctx.mhBrowser.download(session.id, args.url, target.root, exec.signal);
5670
- const closed = closedNotes(ctx, reader, session);
5671
- const text = `${renderDownload(saved)}\n${renderTabs(ctx.mhBrowser.tabs(session.id), closed)}`;
6622
+ const block = tabBlock(ctx, reader, session);
6623
+ const text = `${renderDownload(saved)}${block.suffix}`;
5672
6624
  const download = saved.kind === "saved" ? {
5673
6625
  path: saved.path,
5674
6626
  bytes: saved.bytes,
@@ -5684,8 +6636,9 @@ function browserDownloadTool(ctx) {
5684
6636
  by: META_BY,
5685
6637
  action: "download",
5686
6638
  pages: [],
5687
- closed,
5688
- download
6639
+ closed: block.closed,
6640
+ download,
6641
+ ...block.snap === void 0 ? {} : { tabs: block.snap }
5689
6642
  }))
5690
6643
  };
5691
6644
  }
@@ -5695,11 +6648,11 @@ function browserDownloadTool(ctx) {
5695
6648
  //#region lib/types/tools/handover.js
5696
6649
  const HANDOVER = [
5697
6650
  "Hand the browser to the person and wait until they hand it back.",
5698
- "Call it instead of filling or submitting a login form or a form that issues credentials, and whenever only the person can continue.",
5699
- "The result is the note the person wrote, if any, how many operations they performed, and a snapshot of the tab you named."
6651
+ "The person can take over a tab at any time; that stops the action in progress on that tab, and a tab they have touched stays with them until they hand it back. Other tabs keep working.",
6652
+ "Call it instead of filling or submitting a login form, a Memory login or consent form, or any form that issues credentials, and whenever only the person can continue."
5700
6653
  ].join(" ");
5701
6654
  const REASON = "The sentence the person sees on the handover card, unchanged.";
5702
- const TAB = "Tab id copied from the latest browser result; it stays in use until the person hands it back. Omit it to hand over without naming a tab.";
6655
+ const TAB = "Tab id of the human page to show the person, copied from a page element's tab attribute. Omit it to hand over without naming a tab; that does not pick the latest result. A page with an Agent View has no tab until you open it with snapshot.";
5703
6656
  const ABORT = Symbol("abort");
5704
6657
  function browserHandoverTool(ctx) {
5705
6658
  const reader = logReader(ctx);
@@ -5762,23 +6715,23 @@ function browserHandoverTool(ctx) {
5762
6715
  const back = await ctx.mhBrowser.handBack(session.id, note);
5763
6716
  const page = back.page;
5764
6717
  const pages = page === void 0 ? [] : [page];
5765
- const closed = closedNotes(ctx, reader, session);
5766
- const tabs = ctx.mhBrowser.tabs(session.id);
6718
+ const block = tabBlock(ctx, reader, session);
5767
6719
  return {
5768
6720
  text: `${[
5769
6721
  ...note === void 0 || note === "" ? [] : [note],
5770
6722
  `${String(back.operations)} operations`,
5771
6723
  takeoverText(back)
5772
- ].join("\n")}\n${renderOutcome(pages, tabs, closed)}`,
6724
+ ].join("\n")}\n${renderOutcome(pages)}${block.suffix}`,
5773
6725
  meta: JSON.parse(JSON.stringify({
5774
6726
  ...readingsOf(pages, "handover"),
5775
- closed,
5776
- operations: back.operations
6727
+ closed: block.closed,
6728
+ operations: back.operations,
6729
+ ...block.snap === void 0 ? {} : { tabs: block.snap }
5777
6730
  }))
5778
6731
  };
5779
6732
  } catch (error) {
5780
6733
  if (exec.signal.aborted || isAskAborted(error)) return quiet();
5781
- if (error instanceof MhBrowserError && error.code === "UNKNOWN_TAB") rethrow(new MhBrowserError(UNKNOWN_TAB, `${UNKNOWN_TAB_REASON} ${args.tab ?? ""}`.trim()), renderOutcome([], ctx.mhBrowser.tabs(session.id), closedNotes(ctx, reader, session)));
6734
+ if (error instanceof MhBrowserError && error.code === "UNKNOWN_TAB") rethrow(new MhBrowserError(UNKNOWN_TAB, `${UNKNOWN_TAB_REASON} ${args.tab ?? ""}`.trim()), `${renderOutcome([])}${tabBlock(ctx, reader, session).suffix}`);
5782
6735
  rethrow(error);
5783
6736
  } finally {
5784
6737
  ctx.mhBrowser.releaseIfHeld(session.id);
@@ -5828,10 +6781,10 @@ function isAskAborted(error) {
5828
6781
  //#endregion
5829
6782
  //#region lib/types/tools/invoke.js
5830
6783
  const INVOKE = [
5831
- "Call one action an Agent View declares, as the principal on that site, and return the resulting page.",
5832
- "You name the page and the action; the service builds and sends the page's request, so you never supply a URL or a pinned field.",
5833
- "If the result is STALE_VIEW, read the attached current page before choosing again.",
5834
- "An action declared by a document in requires is called on that document, not on the page that requires it.",
6784
+ "Call one action an Agent View declares, in the principal's name on that site. The call uses the page address and does not need a tab.",
6785
+ "A declared GET returns the resulting page the way browser_open does; any other action returns only the header of the page it lands on, so open that page to read it or act on it.",
6786
+ "The call uses the Agent View you were shown; if the page has changed, the site refuses and the result says so.",
6787
+ "An action declared by a document in requires is called with that document's address as page.",
5835
6788
  "Do not submit a login form or a form that issues credentials; call browser_handover."
5836
6789
  ].join(" ");
5837
6790
  const PAGE = "The url attribute of a page element in a tool result or a presence reminder, or the id in that page's Agent View. When one id names several pages shown to you, use the url of the one you mean.";
@@ -5906,7 +6859,10 @@ function browserInvokeTool(ctx) {
5906
6859
  try {
5907
6860
  const sent = await ctx.mhBrowser.invoke(session.id, resolved.url, {
5908
6861
  name: args.name,
5909
- seen: resolved.seen,
6862
+ shown: {
6863
+ url: resolved.url,
6864
+ text: resolved.text
6865
+ },
5910
6866
  ...args.item === void 0 ? {} : { item: args.item },
5911
6867
  ...input === void 0 ? {} : { input }
5912
6868
  }, {
@@ -5914,20 +6870,20 @@ function browserInvokeTool(ctx) {
5914
6870
  readFile: (value) => resolveBinary(ctx, reader.attachments(session.session), session.cwd, value)
5915
6871
  });
5916
6872
  const pages = [sent.page];
5917
- const closed = closedNotes(ctx, reader, session);
6873
+ const block = tabBlock(ctx, reader, session);
5918
6874
  return {
5919
- text: `${sent.response === void 0 ? "" : `${renderActionResponse(sent.response)}\n`}${"representation" in sent.page ? renderPage(sent.page, void 0, sent.page.inPresence) : renderPageError(sent.page)}\n${renderTabs(ctx.mhBrowser.tabs(session.id), closed)}`,
6875
+ text: `${sent.response === void 0 ? "" : `${renderActionResponse(sent.response)}\n`}${"representation" in sent.page ? renderPage(sent.page, void 0, sent.page.inPresence && !sent.page.headerOnly) : renderPageError(sent.page)}${block.suffix}`,
5920
6876
  meta: JSON.parse(JSON.stringify({
5921
6877
  ...readingsOf(pages, `invoke:${args.name}`),
5922
- closed,
6878
+ closed: block.closed,
6879
+ ...block.snap === void 0 ? {} : { tabs: block.snap },
5923
6880
  ...sent.schemaRejected ? { schemaRejected: true } : {}
5924
6881
  }))
5925
6882
  };
5926
6883
  } catch (error) {
5927
6884
  if (error instanceof MhBrowserError && error.page !== void 0) {
5928
- const stale = error.code === "STALE_VIEW";
5929
- const omit = stale && error.page.inPresence;
5930
- rethrow(error, `${renderPage(error.page, void 0, omit, stale)}\n${renderTabs(ctx.mhBrowser.tabs(session.id), closedNotes(ctx, reader, session))}`);
6885
+ const block = tabBlock(ctx, reader, session);
6886
+ rethrow(error, `${renderPage(error.page, void 0, error.page.inPresence)}${block.suffix}`);
5931
6887
  }
5932
6888
  rethrow(error);
5933
6889
  }
@@ -5947,14 +6903,14 @@ async function resolveBinary(ctx, seen, cwd, value) {
5947
6903
  //#endregion
5948
6904
  //#region lib/types/tools/open.js
5949
6905
  const OPEN = [
5950
- "Read 1 to 8 pages: a page with an Agent View returns that document, any other page returns an aria snapshot.",
5951
- "This is how you read a page once; browser_presence keeps a page in context, browser_invoke calls a declared action, browser_act works a human page.",
5952
- "A page lists what it depends on in requires; those documents are not fetched with it, so open them to read them or to call their actions.",
5953
- "An address with no version attribute is one observation; agent-view-error means the Agent View could not be read and the page has no declared actions."
6906
+ "Read 1 to 8 pages. A page with an Agent View returns that document; any other page returns an aria snapshot.",
6907
+ "A page with no version attribute is one observation. agent-view-error means the Agent View could not be read, so this page is a snapshot and has no declared actions.",
6908
+ "A page lists documents in requires; those are not fetched with it, so open their addresses to read them or to call their actions."
5954
6909
  ].join(" ");
5955
6910
  const PAGES = "The pages to read, 1 to 8, each returned in order as its own element.";
5956
6911
  const URL$1 = "The address to read: from a page shown to you, from the principal, or one you composed; opening a composed address is how it is checked before you use it elsewhere.";
5957
6912
  const WAIT = "Optional wait after the document loads for text to appear or disappear, or for time seconds; at least one must be set. If not met, the page is still returned and waited says so.";
6913
+ const SNAPSHOT = "Ask for the human page: open a tab and return its snapshot, for page operations and handover. A page without an Agent View already does.";
5958
6914
  function browserOpenTool(ctx) {
5959
6915
  const reader = logReader(ctx);
5960
6916
  return defineTool({
@@ -5973,6 +6929,10 @@ function browserOpenTool(ctx) {
5973
6929
  required: true,
5974
6930
  description: URL$1
5975
6931
  },
6932
+ snapshot: {
6933
+ type: "boolean",
6934
+ description: SNAPSHOT
6935
+ },
5976
6936
  waitFor: {
5977
6937
  type: "object",
5978
6938
  additionalProperties: false,
@@ -6038,14 +6998,16 @@ function browserOpenTool(ctx) {
6038
6998
  try {
6039
6999
  const pages = await ctx.mhBrowser.open(session.id, args.pages.map((page) => ({
6040
7000
  url: page.url,
7001
+ ...page.snapshot === true ? { snapshot: true } : {},
6041
7002
  ...page.waitFor === void 0 ? {} : { waitFor: compactWait(page.waitFor) }
6042
7003
  })), exec.signal);
6043
- const closed = closedNotes(ctx, reader, session);
7004
+ const block = tabBlock(ctx, reader, session);
6044
7005
  return {
6045
- text: renderOutcome(pages, ctx.mhBrowser.tabs(session.id), closed, void 0, true),
7006
+ text: `${renderOutcome(pages, void 0, true)}${block.suffix}`,
6046
7007
  meta: JSON.parse(JSON.stringify({
6047
7008
  ...readingsOf(pages, "open"),
6048
- closed
7009
+ closed: block.closed,
7010
+ ...block.snap === void 0 ? {} : { tabs: block.snap }
6049
7011
  }))
6050
7012
  };
6051
7013
  } catch (error) {
@@ -6124,7 +7086,6 @@ function browserPresenceTool(ctx) {
6124
7086
  }),
6125
7087
  async execute(args, exec) {
6126
7088
  const session = sessionFor(ctx, exec);
6127
- if (ctx.mhBrowser.controlOf(session.id) === "person") throw new BrowserToolError("PERSON_IN_CONTROL", "the person is using the browser; call browser_handover to wait until they hand it back");
6128
7089
  const put = args.put ?? [];
6129
7090
  const remove = args.remove ?? [];
6130
7091
  if (put.length + remove.length === 0) throw new BrowserToolError("INVALID_ARGS", "give put, remove, or both");
@@ -6133,15 +7094,16 @@ function browserPresenceTool(ctx) {
6133
7094
  const added = ctx.mhBrowser.presencePut(session.id, put, "model");
6134
7095
  const removed = ctx.mhBrowser.presenceRemove(session.id, remove);
6135
7096
  const receipts = [...added, ...removed];
6136
- const closed = closedNotes(ctx, reader, session);
7097
+ const block = tabBlock(ctx, reader, session);
6137
7098
  return {
6138
- text: `${receipts.map(line).join("\n")}\n${renderTabs(ctx.mhBrowser.tabs(session.id), closed)}`,
7099
+ text: `${receipts.map(line).join("\n")}${block.suffix}`,
6139
7100
  meta: JSON.parse(JSON.stringify({
6140
7101
  by: META_BY,
6141
7102
  action: "presence",
6142
7103
  pages: [],
6143
- closed,
6144
- receipts
7104
+ closed: block.closed,
7105
+ receipts,
7106
+ ...block.snap === void 0 ? {} : { tabs: block.snap }
6145
7107
  }))
6146
7108
  };
6147
7109
  }
@@ -6155,7 +7117,7 @@ function line(receipt) {
6155
7117
  function registerBrowserTools(ctx) {
6156
7118
  followSessionTitles(ctx);
6157
7119
  ctx.tools.register(browserOpenTool(ctx));
6158
- ctx.tools.register(browserActTool(ctx));
7120
+ for (const tool of pageOperationTools(ctx)) ctx.tools.register(tool);
6159
7121
  ctx.tools.register(browserInvokeTool(ctx));
6160
7122
  ctx.tools.register(browserDownloadTool(ctx));
6161
7123
  ctx.tools.register(browserPresenceTool(ctx));