@omercnet/paseo-shared-browser 0.4.2 → 1.0.0-next.162.1

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/README.md CHANGED
@@ -10,6 +10,10 @@ eligible workspace agent acts on the same running page, DOM, navigation state, a
10
10
  Many viewers can watch; human control remains server-authoritative and takes priority over agent
11
11
  input.
12
12
 
13
+ ## Demo
14
+
15
+ https://github.com/user-attachments/assets/37d09fff-0750-440d-983d-426150b0724d
16
+
13
17
  ## Screenshots
14
18
 
15
19
  Both PNGs show the same isolated Chromium session from a dedicated Paseo test daemon. They were
@@ -72,24 +76,10 @@ paseo plugin install npm:@omercnet/paseo-shared-browser
72
76
  paseo plugin ls
73
77
  ```
74
78
 
75
- Or install from Git:
76
-
77
- ```bash
78
- paseo plugin add omercnet/paseo-plugins:paseo-shared-browser
79
- paseo plugin ls
80
- ```
81
-
82
- Installation runs `npm ci --include=dev` and `npm run prepare:runtime` on the daemon host. The
83
- preparation step installs the pinned browser runtime and builds both `supervisor.cjs` and
79
+ When Paseo acquires the npm package, it runs the `npm run prepare:runtime` build hook automatically. The
80
+ hook installs the pinned browser runtime and builds both `supervisor.cjs` and
84
81
  `shared-browser-mcp.cjs` under
85
- `$PASEO_HOME/plugin-data/shared-browser/runtime`. For a local monorepo checkout:
86
-
87
- ```bash
88
- cd paseo-plugins/paseo-shared-browser
89
- npm ci --include=dev
90
- npm run prepare:runtime
91
- paseo plugin install "$PWD"
92
- ```
82
+ `$PASEO_HOME/plugin-data/shared-browser/runtime`.
93
83
 
94
84
  The build requires Node.js 24 or newer and npm on the daemon host. On Linux x64, macOS, and
95
85
  Windows, runtime preparation installs and stages the platform's Chrome for Testing distribution. On
@@ -162,9 +152,9 @@ User-supplied `AGENT_BROWSER_*` variables are deliberately ignored.
162
152
  isolation boundary between local OS users.
163
153
  - `agent-browser` IPC metadata and workspace profile directories are owner-only on POSIX systems.
164
154
  The plugin rejects a non-loopback CDP endpoint.
165
- - Viewer and control tokens coordinate clients already paired to the same Paseo daemon. Paseo v0.8
166
- plugin RPC callbacks expose no authenticated caller identity, so these human-viewer tokens are a
167
- workflow safeguard, not an authorization boundary. The stdio MCP adapter separately uses an
155
+ - Viewer and control tokens coordinate clients already paired to the same Paseo daemon. Plugin RPC
156
+ callbacks expose no authenticated caller identity, so these human-viewer tokens are a workflow
157
+ safeguard, not an authorization boundary. The stdio MCP adapter separately uses an
168
158
  opaque, workspace-bound credential.
169
159
  - Downloads, uploads, clipboard synchronization, media permissions, extensions, native passkeys,
170
160
  and platform authenticators are not exposed by this plugin.
@@ -175,23 +165,20 @@ User-supplied `AGENT_BROWSER_*` variables are deliberately ignored.
175
165
  ## Develop
176
166
 
177
167
  ```bash
178
- npm ci
179
- npm run typecheck
180
- npm run lint
181
- npm run format:check
182
- npm run test:unit
183
- npm run prepare:runtime
184
- paseo plugin install "$PWD"
185
- paseo plugin reload shared-browser
168
+ bun install
169
+ bun run typecheck
170
+ bun run lint
171
+ bun run format:check
172
+ bun run test:unit
173
+ bun run prepare:runtime
186
174
  ```
187
175
 
188
- `npm run test:smoke` launches the configured real Chromium runtime and exercises two viewers,
176
+ `bun run test:smoke` launches the configured real Chromium runtime and exercises two viewers,
189
177
  control handoff, reconnect, stale-frame rejection, viewport changes, device emulation, profile
190
178
  persistence, and archive teardown.
191
179
 
192
180
  Release Please maintains the version, changelog, component tag, and GitHub release from
193
181
  Conventional Commits in the monorepo.
194
182
 
195
- Both the Paseo daemon and app must satisfy `^0.8.0 || ^0.9.0-beta.1`, which supports Paseo 0.8
196
- and the Paseo 0.9 beta line. The client surface uses React Native primitives and works in desktop,
197
- web, iOS, and Android Paseo clients.
183
+ Both the Paseo daemon and app must satisfy `^0.9.0`. The client surface uses React Native primitives
184
+ and works in desktop, web, iOS, and Android Paseo clients.
@@ -1,46 +1,46 @@
1
- import { useMutation, useQuery } from "@tanstack/react-query";
2
1
  import {
3
2
  type PluginClientContext,
4
3
  type PluginWorkspacePanelProps,
5
4
  useRpc,
6
5
  } from "@getpaseo/plugin/client";
7
6
  import { Icon, Modal, ScrollView, TextInput } from "@getpaseo/plugin/client/react-native";
8
- import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
7
+ import { useMutation, useQuery } from "@tanstack/react-query";
8
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
  import {
10
10
  ActivityIndicator,
11
+ type GestureResponderEvent,
11
12
  Image,
13
+ type LayoutChangeEvent,
12
14
  PanResponder,
13
15
  Pressable,
16
+ type StyleProp,
14
17
  StyleSheet,
15
18
  Text,
16
- View,
17
- type GestureResponderEvent,
18
- type LayoutChangeEvent,
19
- type StyleProp,
20
19
  type TextInputProps,
21
20
  type TextStyle,
21
+ View,
22
22
  type ViewStyle,
23
23
  } from "react-native";
24
24
  import {
25
- DEVICE_PRESETS,
26
- MAX_VIEWPORT,
27
- MIN_VIEWPORT,
28
25
  acquireControlRpc,
29
26
  applyDevicePresetRpc,
30
27
  attachBrowserRpc,
28
+ type BrowserFrame,
29
+ type BrowserInputEvent,
30
+ type BrowserState,
31
31
  captureBrowserRpc,
32
+ DEVICE_PRESETS,
33
+ type DevicePresetId,
32
34
  detachBrowserRpc,
35
+ didBrowserRuntimeRestart,
36
+ isBrowserStateCurrent,
33
37
  listOpenBrowserWorkspacesRpc,
38
+ MAX_VIEWPORT,
39
+ MIN_VIEWPORT,
34
40
  navigateBrowserRpc,
35
41
  releaseControlRpc,
36
42
  resizeBrowserRpc,
37
43
  sendBrowserInputRpc,
38
- didBrowserRuntimeRestart,
39
- isBrowserStateCurrent,
40
- type BrowserFrame,
41
- type BrowserInputEvent,
42
- type BrowserState,
43
- type DevicePresetId,
44
44
  } from "../shared/browser";
45
45
 
46
46
  const SPACE = {
@@ -72,6 +72,7 @@ const CAPTURE_INTERVAL_READY = 250;
72
72
  const CAPTURE_INTERVAL_WAITING = 1_500;
73
73
  const MAX_VIEWER_LABEL_LENGTH = 64;
74
74
  const PILL_PRESENCE_POLL_MS = 2_000;
75
+ const AGENT_DIRECTORY_PAGE_LIMIT = 200;
75
76
  const MAX_URL_LENGTH = 8_192;
76
77
  const MAX_TEXT_LENGTH = 4_000;
77
78
  const BYTES_PER_KIBIBYTE = 1_024;
@@ -803,18 +804,33 @@ function CanvasPlaceholder({
803
804
  );
804
805
  }
805
806
 
807
+ interface AgentDirectoryPage {
808
+ entries: Array<{ agent: { id: string; workspaceId?: string | undefined } }>;
809
+ pageInfo: { hasMore: boolean; nextCursor: string | null };
810
+ }
811
+
812
+ type AgentDirectoryUpdate =
813
+ | { kind: "remove"; agentId: string }
814
+ | { kind: "upsert"; agent: { id: string; workspaceId?: string | undefined } };
815
+ type AgentPlacement = { id: string; workspaceId: string };
816
+
806
817
  export function contributeSharedBrowserClient(client: PluginClientContext) {
807
- const agents = new Map<string, { id: string; workspaceId: string }>();
818
+ const agents = new Map<string, AgentPlacement>();
808
819
  const pills = new Map<string, { workspaceId: string; remove: () => void }>();
820
+ const lifetime = new AbortController();
809
821
  let openWorkspaceIds = new Set<string>();
810
822
  let refreshing = false;
811
823
  let stopped = false;
824
+ let directoryGeneration = 0;
825
+ let pendingDirectory: { generation: number; updates: AgentDirectoryUpdate[] } | null = null;
826
+ let unsubscribeDirectory: (() => void) | null = null;
827
+ let releaseDirectory: (() => Promise<void>) | null = null;
812
828
 
813
829
  const removePill = (agentId: string) => {
814
830
  pills.get(agentId)?.remove();
815
831
  pills.delete(agentId);
816
832
  };
817
- const syncPill = (agent: { id: string; workspaceId: string }) => {
833
+ const syncPill = (agent: AgentPlacement) => {
818
834
  const current = pills.get(agent.id);
819
835
  if (!openWorkspaceIds.has(agent.workspaceId)) {
820
836
  removePill(agent.id);
@@ -858,32 +874,94 @@ export function contributeSharedBrowserClient(client: PluginClientContext) {
858
874
  refreshing = false;
859
875
  }
860
876
  };
861
-
862
- const unsubscribe = client.paseo.agents.subscribe((update) => {
877
+ const applyUpdate = (target: Map<string, AgentPlacement>, update: AgentDirectoryUpdate) => {
878
+ if (update.kind === "remove") {
879
+ target.delete(update.agentId);
880
+ return;
881
+ }
882
+ const { id, workspaceId } = update.agent;
883
+ if (workspaceId) target.set(id, { id, workspaceId });
884
+ else target.delete(id);
885
+ };
886
+ const applyLiveUpdate = (update: AgentDirectoryUpdate) => {
887
+ if (stopped) return;
888
+ pendingDirectory?.updates.push(update);
889
+ applyUpdate(agents, update);
863
890
  if (update.kind === "remove") {
864
- agents.delete(update.agentId);
865
891
  removePill(update.agentId);
866
892
  return;
867
893
  }
868
894
  const { id, workspaceId } = update.agent;
869
895
  if (!workspaceId) {
870
- agents.delete(id);
871
896
  removePill(id);
872
897
  return;
873
898
  }
874
- const agent = { id, workspaceId };
875
- agents.set(id, agent);
876
- syncPill(agent);
899
+ syncPill({ id, workspaceId });
877
900
  void refreshPresence();
878
- });
879
- void client.paseo.agents
880
- .list()
881
- .then(async ({ entries }) => {
882
- for (const { agent } of entries) {
883
- if (agent.workspaceId)
884
- agents.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
901
+ };
902
+ const replaceAgents = (next: Map<string, AgentPlacement>) => {
903
+ for (const agentId of agents.keys()) {
904
+ if (!next.has(agentId)) removePill(agentId);
905
+ }
906
+ agents.clear();
907
+ for (const [agentId, agent] of next) agents.set(agentId, agent);
908
+ syncAllPills();
909
+ };
910
+ const followSnapshot = async (snapshot: AgentDirectoryPage) => {
911
+ if (stopped) return;
912
+ const generation = ++directoryGeneration;
913
+ const transaction = { generation, updates: [] as AgentDirectoryUpdate[] };
914
+ pendingDirectory = transaction;
915
+ const next = new Map<string, AgentPlacement>();
916
+ for (const { agent } of snapshot.entries) {
917
+ if (agent.workspaceId) next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
918
+ }
919
+
920
+ try {
921
+ let cursor = snapshot.pageInfo.hasMore ? snapshot.pageInfo.nextCursor : null;
922
+ while (cursor) {
923
+ const page = await client.paseo.agents.list({
924
+ scope: "active",
925
+ page: { limit: AGENT_DIRECTORY_PAGE_LIMIT, cursor },
926
+ signal: lifetime.signal,
927
+ });
928
+ if (stopped || pendingDirectory?.generation !== generation) return;
929
+ for (const { agent } of page.entries) {
930
+ if (agent.workspaceId)
931
+ next.set(agent.id, { id: agent.id, workspaceId: agent.workspaceId });
932
+ }
933
+ cursor = page.pageInfo.hasMore ? page.pageInfo.nextCursor : null;
885
934
  }
935
+ if (stopped || pendingDirectory?.generation !== generation) return;
936
+ for (const update of transaction.updates) applyUpdate(next, update);
937
+ pendingDirectory = null;
938
+ replaceAgents(next);
886
939
  await refreshPresence();
940
+ } catch {
941
+ if (!stopped && pendingDirectory?.generation === generation) pendingDirectory = null;
942
+ }
943
+ };
944
+
945
+ void client.paseo.agents
946
+ .list({
947
+ scope: "active",
948
+ page: { limit: AGENT_DIRECTORY_PAGE_LIMIT },
949
+ subscribe: {},
950
+ signal: lifetime.signal,
951
+ })
952
+ .then(({ subscription }) => {
953
+ if (stopped) {
954
+ void subscription.release().catch(() => undefined);
955
+ return undefined;
956
+ }
957
+ releaseDirectory = subscription.release;
958
+ unsubscribeDirectory = subscription.subscribe({
959
+ snapshot: (snapshot) => void followSnapshot(snapshot),
960
+ update: (message) => {
961
+ if (message.type === "agent_update") applyLiveUpdate(message.payload);
962
+ },
963
+ });
964
+ return undefined;
887
965
  })
888
966
  .catch(() => undefined);
889
967
  const presenceTimer = setInterval(() => void refreshPresence(), PILL_PRESENCE_POLL_MS);
@@ -891,8 +969,12 @@ export function contributeSharedBrowserClient(client: PluginClientContext) {
891
969
  return () => {
892
970
  if (stopped) return;
893
971
  stopped = true;
972
+ directoryGeneration += 1;
973
+ pendingDirectory = null;
894
974
  clearInterval(presenceTimer);
895
- unsubscribe();
975
+ unsubscribeDirectory?.();
976
+ void releaseDirectory?.().catch(() => undefined);
977
+ lifetime.abort();
896
978
  for (const { remove } of pills.values()) remove();
897
979
  pills.clear();
898
980
  agents.clear();
package/index.client.tsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
- import { SharedBrowserPanel, contributeSharedBrowserClient } from "./client/browser";
2
+ import { contributeSharedBrowserClient, SharedBrowserPanel } from "./client/browser";
3
3
 
4
4
  export default function contribute(client: PluginClientContext) {
5
5
  client.addWorkspacePanel({
package/index.server.ts CHANGED
@@ -1,7 +1,7 @@
1
- import type { PluginServerContext } from "@getpaseo/plugin/server";
2
1
  import { randomBytes } from "node:crypto";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
5
5
  import {
6
6
  bindAgentTicket,
7
7
  cleanupBrowserServer,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "0.4.2",
3
+ "version": "1.0.0-next.162.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
@@ -38,37 +38,35 @@
38
38
  "access": "public"
39
39
  },
40
40
  "scripts": {
41
+ "check": "biome check .",
42
+ "check:write": "biome check --write .",
41
43
  "typecheck": "tsc --noEmit",
42
- "lint": "oxlint --deny-warnings .",
43
- "format": "oxfmt --ignore-path .formatignore .",
44
- "format:check": "oxfmt --check --ignore-path .formatignore .",
45
44
  "prepare:runtime": "node scripts/prepare-runtime.mjs",
46
- "test:unit": "node --test scripts/prepare-dependencies.test.mjs && vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts",
45
+ "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts",
46
+ "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts",
47
47
  "test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
48
48
  },
49
49
  "dependencies": {
50
- "@getpaseo/plugin": "0.8.0",
50
+ "@getpaseo/plugin": "0.9.2",
51
51
  "@modelcontextprotocol/sdk": "1.30.0",
52
52
  "agent-browser": "0.37.1",
53
53
  "esbuild": "0.28.2",
54
54
  "zod": "4.4.3"
55
55
  },
56
56
  "devDependencies": {
57
- "@getpaseo/plugin": "0.9.0-beta.1",
58
- "@tanstack/react-query": "^5.90.11",
59
- "@types/node": "^22.0.0",
60
- "@types/react": "~19.2.0",
61
- "oxfmt": "0.66.0",
62
- "oxlint": "1.81.0",
57
+ "@biomejs/biome": "^2.5.10",
58
+ "@tanstack/react-query": "^5.102.3",
59
+ "@types/node": "^24.10.1",
60
+ "@types/react": "^19.2.18",
63
61
  "react": "19.1.0",
64
62
  "react-native": "0.81.5",
65
- "typescript": "^7.0.0",
63
+ "typescript": "^7.0.2",
66
64
  "vitest": "^5.0.0"
67
65
  },
68
66
  "engines": {
69
67
  "node": ">=24"
70
68
  },
71
- "packageManager": "npm@11.19.1",
69
+ "packageManager": "bun@1.4.2",
72
70
  "allowScripts": {
73
71
  "agent-browser": true,
74
72
  "esbuild@0.28.2": true
package/paseo-plugin.json CHANGED
@@ -1,10 +1,6 @@
1
1
  {
2
2
  "id": "shared-browser",
3
- "requirements": {
4
- "paseo": "^0.8.0 || ^0.9.0-beta.1"
5
- },
6
- "build": [
7
- ["node", "scripts/prepare-dependencies.mjs"],
8
- ["npm", "run", "prepare:runtime"]
9
- ]
3
+ "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
4
+ "requirements": { "paseo": "^0.9.0" },
5
+ "build": [["node", "scripts/prepare-dependencies.mjs"], ["npm", "run", "prepare:runtime"]]
10
6
  }
@@ -1,21 +1,58 @@
1
- import { createRequire } from "node:module";
2
1
  import { execFileSync } from "node:child_process";
3
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
 
7
7
  const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
8
8
 
9
+ function loadRootCatalog(root) {
10
+ for (let directory = root; ; directory = dirname(directory)) {
11
+ const manifestPath = join(directory, "package.json");
12
+ if (existsSync(manifestPath)) {
13
+ const catalog = JSON.parse(readFileSync(manifestPath, "utf8")).catalog;
14
+ if (catalog) return catalog;
15
+ }
16
+ if (directory === dirname(directory)) return null;
17
+ }
18
+ }
19
+
20
+ function stageManifest(root, catalog, install) {
21
+ const manifestPath = join(root, "package.json");
22
+ const source = readFileSync(manifestPath, "utf8");
23
+ const manifest = JSON.parse(source);
24
+
25
+ for (const [name, spec] of Object.entries(manifest.dependencies)) {
26
+ if (spec !== "catalog:") continue;
27
+ if (typeof catalog[name] !== "string") throw new Error(`Missing catalog entry for ${name}`);
28
+ manifest.dependencies[name] = catalog[name];
29
+ }
30
+ delete manifest.devDependencies;
31
+
32
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
33
+ try {
34
+ install();
35
+ } finally {
36
+ writeFileSync(manifestPath, source);
37
+ }
38
+ }
39
+
9
40
  export function resolveDependencyRoot(packageName, from = import.meta.url) {
10
- const require = createRequire(from);
11
- return dirname(require.resolve(`${packageName}/package.json`));
41
+ return dirname(createRequire(from).resolve(`${packageName}/package.json`));
12
42
  }
13
- export function prepareDependencies(root = projectRoot, execute = execFileSync) {
14
- if (!existsSync(join(root, "package-lock.json"))) return false;
15
43
 
16
- execute(process.platform === "win32" ? "npm.cmd" : "npm", ["ci", "--include=dev"], {
17
- cwd: root,
18
- stdio: "inherit",
44
+ export function prepareDependencies(root = projectRoot, execute = execFileSync) {
45
+ const catalog = loadRootCatalog(root);
46
+ if (!catalog) return false;
47
+ stageManifest(root, catalog, () => {
48
+ execute(
49
+ process.platform === "win32" ? "npm.cmd" : "npm",
50
+ ["install", "--omit=dev", "--ignore-scripts", "--no-package-lock", "--workspaces=false"],
51
+ {
52
+ cwd: root,
53
+ stdio: "inherit",
54
+ },
55
+ );
19
56
  });
20
57
  return true;
21
58
  }
@@ -1,6 +1,5 @@
1
- import { resolveDependencyRoot } from "./prepare-dependencies.mjs";
2
- import { build } from "esbuild";
3
1
  import { execFile } from "node:child_process";
2
+ import { constants } from "node:fs";
4
3
  import {
5
4
  access,
6
5
  chmod,
@@ -13,11 +12,19 @@ import {
13
12
  symlink,
14
13
  writeFile,
15
14
  } from "node:fs/promises";
16
- import { constants } from "node:fs";
15
+ import { createRequire } from "node:module";
17
16
  import { arch, homedir, platform, userInfo } from "node:os";
18
17
  import { dirname, join, resolve } from "node:path";
19
- import { promisify } from "node:util";
20
18
  import { fileURLToPath } from "node:url";
19
+ import { promisify } from "node:util";
20
+ import { build } from "esbuild";
21
+
22
+ const require = createRequire(import.meta.url);
23
+
24
+ function resolveDependencyRoot(packageName) {
25
+ const packageJsonPath = require.resolve(`${packageName}/package.json`);
26
+ return dirname(packageJsonPath);
27
+ }
21
28
 
22
29
  const execFileAsync = promisify(execFile);
23
30
  const expectedVersion = "0.37.1";
@@ -54,7 +61,9 @@ async function requireExecutable(path, label) {
54
61
  }
55
62
 
56
63
  async function validateAgentBrowser(path) {
57
- const { stdout } = await execFileAsync(path, ["--version"], { encoding: "utf8" });
64
+ const { stdout } = await execFileAsync(path, ["--version"], {
65
+ encoding: "utf8",
66
+ });
58
67
  const version = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)?.[0];
59
68
  if (version !== expectedVersion) {
60
69
  throw new Error(`Expected agent-browser ${expectedVersion}, received ${version ?? "unknown"}`);
@@ -185,7 +194,10 @@ async function installChromium() {
185
194
  if (!releaseDir) throw new Error("agent-browser install did not produce a Chromium release");
186
195
  const chrome = installedChromiumExecutable(releaseDir);
187
196
  await requireExecutable(chrome, "Installed Chromium executable");
188
- await cp(releaseDir, join(stagingRoot, "chromium"), { recursive: true, force: true });
197
+ await cp(releaseDir, join(stagingRoot, "chromium"), {
198
+ recursive: true,
199
+ force: true,
200
+ });
189
201
  if (hostPlatform === "darwin") {
190
202
  // chrome can't be a symlink to the .app's real binary here: macOS dyld
191
203
  // resolves @executable_path from the invoked path's own directory, so a
@@ -3,14 +3,14 @@ import { chmod, mkdir, readFile, rm } from "node:fs/promises";
3
3
  import { isAbsolute, resolve } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import {
6
+ attachToTarget,
6
7
  CdpConnection,
7
- CdpSession,
8
+ type CdpEvent,
9
+ type CdpSession,
10
+ type CdpTarget,
8
11
  CdpUnavailableError,
9
12
  CdpUnknownOutcomeError,
10
- attachToTarget,
11
13
  listPageTargets,
12
- type CdpEvent,
13
- type CdpTarget,
14
14
  } from "./cdp";
15
15
 
16
16
  const execFileAsync = promisify(execFile);
@@ -93,6 +93,7 @@ export interface AgentBrowserRuntimeOptions {
93
93
  ipcDirectory: string;
94
94
  session: string;
95
95
  initialUrl?: string;
96
+ headed?: boolean;
96
97
  timeoutMs?: number;
97
98
  }
98
99
 
@@ -164,9 +165,7 @@ function parseJsonOutput(stdout: string): unknown {
164
165
  for (const line of lines) {
165
166
  try {
166
167
  return JSON.parse(line);
167
- } catch {
168
- continue;
169
- }
168
+ } catch {}
170
169
  }
171
170
  throw new AgentBrowserIncompatibleError("agent-browser returned invalid JSON");
172
171
  }
@@ -191,6 +190,7 @@ export class AgentBrowserRuntime {
191
190
  readonly ipcDirectory: string;
192
191
  readonly session: string;
193
192
  private readonly initialUrl: string;
193
+ private readonly headed: boolean;
194
194
  private readonly timeoutMs: number;
195
195
  private readonly environment: NodeJS.ProcessEnv;
196
196
  private connection: CdpConnection | null = null;
@@ -214,6 +214,7 @@ export class AgentBrowserRuntime {
214
214
  throw new AgentBrowserIncompatibleError("agent-browser session name is invalid");
215
215
  }
216
216
  this.session = options.session;
217
+ this.headed = options.headed ?? false;
217
218
  this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
218
219
  this.environment = runtimeEnvironment(this.ipcDirectory);
219
220
  this.initialUrl = options.initialUrl ?? "about:blank";
@@ -238,6 +239,7 @@ export class AgentBrowserRuntime {
238
239
  this.profilePath,
239
240
  "--executable-path",
240
241
  this.executablePath,
242
+ ...(this.headed ? ["--headed"] : []),
241
243
  ...(chromiumArguments ? ["--args", chromiumArguments] : []),
242
244
  "--json",
243
245
  "open",
@@ -508,9 +510,7 @@ export class AgentBrowserRuntime {
508
510
 
509
511
  async mouseMove(x: number, y: number): Promise<void> {
510
512
  this.assertPoint(x, y);
511
- await (
512
- await this.requirePage()
513
- ).send(
513
+ await (await this.requirePage()).send(
514
514
  "Input.dispatchMouseEvent",
515
515
  {
516
516
  type: "mouseMoved",
@@ -530,9 +530,7 @@ export class AgentBrowserRuntime {
530
530
  clickCount = 1,
531
531
  ): Promise<void> {
532
532
  this.assertPoint(x, y);
533
- await (
534
- await this.requirePage()
535
- ).send(
533
+ await (await this.requirePage()).send(
536
534
  "Input.dispatchMouseEvent",
537
535
  {
538
536
  type: "mousePressed",
@@ -549,9 +547,7 @@ export class AgentBrowserRuntime {
549
547
 
550
548
  async mouseUp(x: number, y: number, button: MouseButton = "left", clickCount = 1): Promise<void> {
551
549
  this.assertPoint(x, y);
552
- await (
553
- await this.requirePage()
554
- ).send(
550
+ await (await this.requirePage()).send(
555
551
  "Input.dispatchMouseEvent",
556
552
  {
557
553
  type: "mouseReleased",
@@ -568,9 +564,7 @@ export class AgentBrowserRuntime {
568
564
 
569
565
  async wheel(x: number, y: number, deltaX: number, deltaY: number): Promise<void> {
570
566
  this.assertPoint(x, y);
571
- await (
572
- await this.requirePage()
573
- ).send(
567
+ await (await this.requirePage()).send(
574
568
  "Input.dispatchMouseEvent",
575
569
  {
576
570
  type: "mouseWheel",
@@ -590,9 +584,7 @@ export class AgentBrowserRuntime {
590
584
  }
591
585
 
592
586
  async keyDown(key: string, code = key): Promise<void> {
593
- await (
594
- await this.requirePage()
595
- ).send(
587
+ await (await this.requirePage()).send(
596
588
  "Input.dispatchKeyEvent",
597
589
  {
598
590
  type: "keyDown",
@@ -606,9 +598,11 @@ export class AgentBrowserRuntime {
606
598
  }
607
599
 
608
600
  async keyUp(key: string, code = key): Promise<void> {
609
- await (
610
- await this.requirePage()
611
- ).send("Input.dispatchKeyEvent", { type: "keyUp", key, code }, { mutation: true });
601
+ await (await this.requirePage()).send(
602
+ "Input.dispatchKeyEvent",
603
+ { type: "keyUp", key, code },
604
+ { mutation: true },
605
+ );
612
606
  this.heldKeys.delete(key);
613
607
  }
614
608
 
@@ -617,9 +611,7 @@ export class AgentBrowserRuntime {
617
611
  points: Array<{ x: number; y: number; id?: number }>,
618
612
  ): Promise<void> {
619
613
  for (const point of points) this.assertPoint(point.x, point.y);
620
- await (
621
- await this.requirePage()
622
- ).send(
614
+ await (await this.requirePage()).send(
623
615
  "Input.dispatchTouchEvent",
624
616
  {
625
617
  type,
@@ -681,9 +673,10 @@ export class AgentBrowserRuntime {
681
673
  if (!force) {
682
674
  try {
683
675
  await this.invoke(["--session", this.session, "--json", "close"]);
684
- } catch (error) {
676
+ } catch {
685
677
  this.stopping = false;
686
- throw error;
678
+ await this.shutdown(true);
679
+ return;
687
680
  }
688
681
  } else {
689
682
  const pid = await this.daemonPid();
@@ -1,20 +1,20 @@
1
- import type { RpcInput } from "@getpaseo/plugin";
2
1
  import { randomBytes } from "node:crypto";
2
+ import type { RpcInput } from "@getpaseo/plugin";
3
3
  import {
4
- DEVICE_PRESETS,
4
+ type applyDevicePresetRpc,
5
+ type BrowserFrame,
6
+ type BrowserInputEvent,
7
+ type BrowserState,
5
8
  DEFAULT_VIEWPORT,
9
+ DEVICE_PRESETS,
10
+ type DevicePresetId,
6
11
  FRAME_MAX_BYTES,
7
12
  MAX_VIEWPORT,
8
13
  MIN_VIEWPORT,
9
14
  mapDisplayedPoint,
10
- applyDevicePresetRpc,
11
- navigateBrowserRpc,
12
- resizeBrowserRpc,
13
- sendBrowserInputRpc,
14
- type BrowserFrame,
15
- type BrowserInputEvent,
16
- type BrowserState,
17
- type DevicePresetId,
15
+ type navigateBrowserRpc,
16
+ type resizeBrowserRpc,
17
+ type sendBrowserInputRpc,
18
18
  type Viewport,
19
19
  } from "../shared/browser";
20
20
  import type { JsonValue } from "./runtime-protocol";
package/server/browser.ts CHANGED
@@ -1,11 +1,11 @@
1
- import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
2
- import type { PluginHandlerContext } from "@getpaseo/plugin/server";
3
1
  import { spawn } from "node:child_process";
4
2
  import { randomUUID } from "node:crypto";
5
3
  import { access } from "node:fs/promises";
6
4
  import { homedir } from "node:os";
7
5
  import { join } from "node:path";
8
- import {
6
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
7
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
8
+ import type {
9
9
  acquireControlRpc,
10
10
  applyDevicePresetRpc,
11
11
  attachBrowserRpc,
@@ -18,15 +18,15 @@ import {
18
18
  sendBrowserInputRpc,
19
19
  } from "../shared/browser";
20
20
  import type { JsonValue } from "./runtime-protocol";
21
- import { SupervisorClient } from "./supervisor-client";
22
21
  import { resolveSupervisorPaths } from "./supervisor";
22
+ import { SupervisorClient } from "./supervisor-client";
23
23
 
24
- export { SessionManager, normalizeBrowserUrl } from "./browser-policy";
25
24
  export type {
26
25
  BrowserRuntimeClient,
27
26
  SessionManagerOptions,
28
27
  WorkspaceValidator,
29
28
  } from "./browser-policy";
29
+ export { normalizeBrowserUrl, SessionManager } from "./browser-policy";
30
30
 
31
31
  type AttachInput = RpcInput<typeof attachBrowserRpc>;
32
32
  type AttachOutput = RpcOutput<typeof attachBrowserRpc>;
package/server/cdp.ts CHANGED
@@ -102,7 +102,11 @@ export class CdpConnection extends EventEmitter {
102
102
  async send<T = unknown>(
103
103
  method: string,
104
104
  params: Record<string, unknown> = {},
105
- options: { sessionId?: string; mutation?: boolean; timeoutMs?: number } = {},
105
+ options: {
106
+ sessionId?: string;
107
+ mutation?: boolean;
108
+ timeoutMs?: number;
109
+ } = {},
106
110
  ): Promise<T> {
107
111
  if (!this.isOpen) throw new CdpUnavailableError("CDP connection is closed");
108
112
  const id = this.nextId++;
@@ -173,7 +177,10 @@ export class CdpConnection extends EventEmitter {
173
177
  return;
174
178
  }
175
179
  if (message.method) {
176
- const event: CdpEvent = { method: message.method, params: message.params ?? {} };
180
+ const event: CdpEvent = {
181
+ method: message.method,
182
+ params: message.params ?? {},
183
+ };
177
184
  if (message.sessionId) event.sessionId = message.sessionId;
178
185
  this.emit("event", event);
179
186
  this.emit(message.method, event.params, event.sessionId);
@@ -220,7 +227,10 @@ export class CdpSession extends EventEmitter {
220
227
  params: Record<string, unknown> = {},
221
228
  options: { mutation?: boolean; timeoutMs?: number } = {},
222
229
  ): Promise<T> {
223
- return this.connection.send<T>(method, params, { ...options, sessionId: this.sessionId });
230
+ return this.connection.send<T>(method, params, {
231
+ ...options,
232
+ sessionId: this.sessionId,
233
+ });
224
234
  }
225
235
 
226
236
  detach(): Promise<void> {
@@ -1,11 +1,11 @@
1
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
1
  import { homedir } from "node:os";
4
2
  import { join } from "node:path";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import type { JsonValue } from "./runtime-protocol";
7
- import { AgentSupervisorClient } from "./supervisor-client";
8
7
  import { resolveSupervisorPaths } from "./supervisor";
8
+ import { AgentSupervisorClient } from "./supervisor-client";
9
9
 
10
10
  const TICKET_ENV = "PASEO_SHARED_BROWSER_TICKET";
11
11
  const MIN_VIEWPORT = { width: 320, height: 480 } as const;
@@ -71,7 +71,9 @@ function asObject(value: JsonValue): Record<string, JsonValue> {
71
71
  }
72
72
 
73
73
  function textResult(value: JsonValue) {
74
- return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
74
+ return {
75
+ content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }],
76
+ };
75
77
  }
76
78
 
77
79
  async function main(): Promise<void> {
@@ -83,7 +85,10 @@ async function main(): Promise<void> {
83
85
  });
84
86
  await client.open();
85
87
 
86
- const server = new McpServer({ name: "paseo-shared-browser", version: "0.2.2" });
88
+ const server = new McpServer({
89
+ name: "paseo-shared-browser",
90
+ version: "0.2.2",
91
+ });
87
92
 
88
93
  server.registerTool(
89
94
  "shared_browser_status",
@@ -100,7 +105,9 @@ async function main(): Promise<void> {
100
105
  {
101
106
  description:
102
107
  "Capture the current shared browser frame and state. Capture before sending input.",
103
- inputSchema: z.object({ quality: z.enum(["low", "medium", "high"]).default("medium") }),
108
+ inputSchema: z.object({
109
+ quality: z.enum(["low", "medium", "high"]).default("medium"),
110
+ }),
104
111
  annotations: { readOnlyHint: true, idempotentHint: true },
105
112
  },
106
113
  async ({ quality }) => {
@@ -113,7 +120,11 @@ async function main(): Promise<void> {
113
120
  if (frame && typeof frame === "object" && !Array.isArray(frame)) {
114
121
  const image = frame as Record<string, JsonValue>;
115
122
  if (typeof image.dataBase64 === "string" && typeof image.mimeType === "string")
116
- content.push({ type: "image", data: image.dataBase64, mimeType: image.mimeType });
123
+ content.push({
124
+ type: "image",
125
+ data: image.dataBase64,
126
+ mimeType: image.mimeType,
127
+ });
117
128
  }
118
129
  return { content };
119
130
  },
@@ -125,7 +136,11 @@ async function main(): Promise<void> {
125
136
  description:
126
137
  "Acquire browser control if no human or other viewer currently holds it. Forced takeover is unavailable.",
127
138
  inputSchema: z.object({}),
128
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
139
+ annotations: {
140
+ readOnlyHint: false,
141
+ destructiveHint: false,
142
+ idempotentHint: false,
143
+ },
129
144
  },
130
145
  async () => textResult(await client.request("acquire-control", {})),
131
146
  );
@@ -135,7 +150,11 @@ async function main(): Promise<void> {
135
150
  {
136
151
  description: "Release this agent's browser control lease.",
137
152
  inputSchema: z.object({}),
138
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
153
+ annotations: {
154
+ readOnlyHint: false,
155
+ destructiveHint: false,
156
+ idempotentHint: false,
157
+ },
139
158
  },
140
159
  async () => textResult(await client.request("release-control", {})),
141
160
  );
@@ -145,16 +164,27 @@ async function main(): Promise<void> {
145
164
  description: "Navigate the shared browser using the agent's current observed state.",
146
165
  inputSchema: z.object({
147
166
  action: z.discriminatedUnion("kind", [
148
- z.object({ kind: z.literal("goto"), url: z.string().trim().min(1).max(8_192) }),
167
+ z.object({
168
+ kind: z.literal("goto"),
169
+ url: z.string().trim().min(1).max(8_192),
170
+ }),
149
171
  z.object({ kind: z.literal("back") }),
150
172
  z.object({ kind: z.literal("forward") }),
151
173
  z.object({ kind: z.literal("reload") }),
152
174
  ]),
153
175
  }),
154
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
176
+ annotations: {
177
+ readOnlyHint: false,
178
+ destructiveHint: false,
179
+ idempotentHint: false,
180
+ },
155
181
  },
156
182
  async ({ action }) =>
157
- textResult(await client.request("navigate", { action: action as unknown as JsonValue })),
183
+ textResult(
184
+ await client.request("navigate", {
185
+ action: action as unknown as JsonValue,
186
+ }),
187
+ ),
158
188
  );
159
189
 
160
190
  server.registerTool(
@@ -163,7 +193,11 @@ async function main(): Promise<void> {
163
193
  description:
164
194
  "Send one input event using the exact frame returned by the latest capture. Stale frames are rejected.",
165
195
  inputSchema: z.object({ event: inputEventSchema }),
166
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
196
+ annotations: {
197
+ readOnlyHint: false,
198
+ destructiveHint: false,
199
+ idempotentHint: false,
200
+ },
167
201
  },
168
202
  async ({ event }) =>
169
203
  textResult(await client.request("input", { event: event as unknown as JsonValue })),
@@ -177,10 +211,18 @@ async function main(): Promise<void> {
177
211
  width: z.number().int().min(MIN_VIEWPORT.width).max(MAX_VIEWPORT.width),
178
212
  height: z.number().int().min(MIN_VIEWPORT.height).max(MAX_VIEWPORT.height),
179
213
  }),
180
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
214
+ annotations: {
215
+ readOnlyHint: false,
216
+ destructiveHint: false,
217
+ idempotentHint: false,
218
+ },
181
219
  },
182
220
  async (viewport) =>
183
- textResult(await client.request("viewport", { viewport: viewport as unknown as JsonValue })),
221
+ textResult(
222
+ await client.request("viewport", {
223
+ viewport: viewport as unknown as JsonValue,
224
+ }),
225
+ ),
184
226
  );
185
227
 
186
228
  const transport = new StdioServerTransport();
@@ -11,12 +11,18 @@ interface OwnedRuntime {
11
11
  runtimeId: string;
12
12
  runtime: AgentBrowserRuntime;
13
13
  }
14
+ interface RuntimeOwnerOptions {
15
+ initialUrl?: string;
16
+ headed?: boolean;
17
+ }
14
18
 
15
19
  function paseoHome(): string {
16
20
  return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
17
21
  }
18
22
 
19
- export async function createRuntimeOwner(): Promise<RuntimeOwner<OwnedRuntime>> {
23
+ export async function createRuntimeOwner(
24
+ options: RuntimeOwnerOptions = {},
25
+ ): Promise<RuntimeOwner<OwnedRuntime>> {
20
26
  const root = join(paseoHome(), "plugin-data", "shared-browser");
21
27
  const ipcDirectory =
22
28
  process.platform === "win32"
@@ -46,7 +52,8 @@ export async function createRuntimeOwner(): Promise<RuntimeOwner<OwnedRuntime>>
46
52
  profilePath: join(root, "profiles", hash),
47
53
  ipcDirectory,
48
54
  session: `ws-${hash.slice(0, 16)}`,
49
- initialUrl: DEFAULT_BROWSER_URL,
55
+ initialUrl: options.initialUrl ?? DEFAULT_BROWSER_URL,
56
+ headed: options.headed ?? false,
50
57
  });
51
58
  await runtime.launch();
52
59
  return { runtimeId: randomUUID(), runtime };
@@ -41,7 +41,11 @@ export type AgentBrowserOperation =
41
41
  export type RuntimeRequest =
42
42
  | (AdminRequestBase & { method: "bridge.claim"; takeover?: boolean })
43
43
  | (AdminRequestBase & { method: "bridge.heartbeat"; epoch: number })
44
- | (AdminRequestBase & { method: "workspace.ensure"; epoch: number; workspaceId: string })
44
+ | (AdminRequestBase & {
45
+ method: "workspace.ensure";
46
+ epoch: number;
47
+ workspaceId: string;
48
+ })
45
49
  | (AdminRequestBase & {
46
50
  method: "workspace.request";
47
51
  epoch: number;
@@ -49,14 +53,22 @@ export type RuntimeRequest =
49
53
  operation: string;
50
54
  input: JsonValue;
51
55
  })
52
- | (AdminRequestBase & { method: "workspace.archive"; epoch: number; workspaceId: string })
56
+ | (AdminRequestBase & {
57
+ method: "workspace.archive";
58
+ epoch: number;
59
+ workspaceId: string;
60
+ })
53
61
  | (AdminRequestBase & {
54
62
  method: "browser.request";
55
63
  epoch: number;
56
64
  operation: string;
57
65
  input: JsonValue;
58
66
  })
59
- | (AdminRequestBase & { method: "ticket.issue"; epoch: number; ticket: string })
67
+ | (AdminRequestBase & {
68
+ method: "ticket.issue";
69
+ epoch: number;
70
+ ticket: string;
71
+ })
60
72
  | (AdminRequestBase & {
61
73
  method: "ticket.bind";
62
74
  epoch: number;
@@ -64,7 +76,11 @@ export type RuntimeRequest =
64
76
  agentId: string;
65
77
  workspaceId: string;
66
78
  })
67
- | (AdminRequestBase & { method: "agent.revoke"; epoch: number; agentId: string })
79
+ | (AdminRequestBase & {
80
+ method: "agent.revoke";
81
+ epoch: number;
82
+ agentId: string;
83
+ })
68
84
  | (RuntimeRequestBase & {
69
85
  method: "agent.request";
70
86
  ticket: string;
@@ -76,7 +92,11 @@ export type RuntimeResult = BridgeLease | RuntimeDescriptor | JsonValue | { arch
76
92
 
77
93
  export type RuntimeResponse =
78
94
  | { id: string; ok: true; result: RuntimeResult }
79
- | { id: string; ok: false; error: { code: RuntimeErrorCode; message: string } };
95
+ | {
96
+ id: string;
97
+ ok: false;
98
+ error: { code: RuntimeErrorCode; message: string };
99
+ };
80
100
 
81
101
  export type RuntimeErrorCode =
82
102
  | "AUTHENTICATION_FAILED"
@@ -145,7 +165,14 @@ export function parseRuntimeRequest(value: unknown): RuntimeRequest {
145
165
  };
146
166
  const epoch = requireInteger(value, "epoch");
147
167
  if (method === "bridge.heartbeat")
148
- return { id, version: RUNTIME_PROTOCOL_VERSION, method, token, bridgeId, epoch };
168
+ return {
169
+ id,
170
+ version: RUNTIME_PROTOCOL_VERSION,
171
+ method,
172
+ token,
173
+ bridgeId,
174
+ epoch,
175
+ };
149
176
  if (method === "workspace.ensure" || method === "workspace.archive")
150
177
  return {
151
178
  id,
@@ -1,14 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import { createConnection, type Socket } from "node:net";
3
- import { randomUUID } from "node:crypto";
4
4
  import {
5
- RUNTIME_PROTOCOL_VERSION,
6
- RuntimeProtocolError,
7
- isRuntimeResponse,
8
5
  type AgentBrowserOperation,
9
6
  type BridgeLease,
7
+ isRuntimeResponse,
10
8
  type JsonValue,
9
+ RUNTIME_PROTOCOL_VERSION,
11
10
  type RuntimeDescriptor,
11
+ RuntimeProtocolError,
12
12
  type RuntimeRequest,
13
13
  type RuntimeResponse,
14
14
  type RuntimeResult,
@@ -7,15 +7,15 @@ import type { BrowserFrame, BrowserInputEvent, BrowserState, Viewport } from "..
7
7
  import { SessionManager } from "./browser-policy";
8
8
  import { CdpUnknownOutcomeError } from "./cdp";
9
9
  import {
10
+ type BridgeLease,
10
11
  DEFAULT_BRIDGE_HEARTBEAT_MS,
11
12
  DEFAULT_BRIDGE_TIMEOUT_MS,
12
13
  DEFAULT_ORPHAN_GRACE_MS,
13
- RUNTIME_PROTOCOL_VERSION,
14
- RuntimeProtocolError,
15
- parseRuntimeRequest,
16
- type BridgeLease,
17
14
  type JsonValue,
15
+ parseRuntimeRequest,
16
+ RUNTIME_PROTOCOL_VERSION,
18
17
  type RuntimeDescriptor,
18
+ RuntimeProtocolError,
19
19
  type RuntimeRequest,
20
20
  type RuntimeResponse,
21
21
  type RuntimeResult,
package/shared/browser.ts CHANGED
@@ -206,7 +206,10 @@ export const navigateBrowserRpc = defineRpc({
206
206
  controlToken: opaqueTokenSchema,
207
207
  expected: expectedStateSchema,
208
208
  action: z.discriminatedUnion("kind", [
209
- z.object({ kind: z.literal("goto"), url: z.string().trim().min(1).max(8_192) }),
209
+ z.object({
210
+ kind: z.literal("goto"),
211
+ url: z.string().trim().min(1).max(8_192),
212
+ }),
210
213
  z.object({ kind: z.literal("back") }),
211
214
  z.object({ kind: z.literal("forward") }),
212
215
  z.object({ kind: z.literal("reload") }),