@omercnet/paseo-shared-browser 1.1.0 → 1.2.0-next.214.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
@@ -93,15 +93,19 @@ Before upgrading the plugin on Windows, close active Shared Browser sessions and
93
93
  daemon; Windows does not allow the installer to replace runtime executables that are still running.
94
94
 
95
95
  Open a workspace, search the Command Center for **Open Shared Browser**, or tap the **Shared
96
- Browser** composer pill while a workspace session is open.
96
+ Browser** composer pill while a workspace session is open. On Paseo 0.11 or newer, a **Shared
97
+ Browser (n)** row in the sidebar footer appears while browser sessions are open; it lists them and
98
+ jumps to the chosen workspace's browser panel.
97
99
 
98
100
  ## Agent MCP access
99
101
 
100
102
  The plugin automatically injects its stdio MCP adapter only when a new, non-internal agent is
101
103
  created with a provider that accepts external MCP servers. Agents that already exist, resumed
102
- sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's OMP provider
103
- uses native host-tool injection and rejects external MCP servers, so OMP agents are left unchanged.
104
- Pi agents continue to receive the adapter, but they require Pi's optional MCP support to launch it.
104
+ sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's built-in OMP
105
+ provider accepts session MCP servers from Paseo 0.11, so new OMP agents receive the adapter there.
106
+ On Paseo 0.9 and 0.10 the built-in OMP adapter rejects external MCP servers, so OMP agents are left
107
+ unchanged. Pi agents continue to receive the adapter, but they require Pi's optional MCP support to
108
+ launch it.
105
109
 
106
110
  The injected MCP server exposes exactly these tools: `shared_browser_status`,
107
111
  `shared_browser_capture`, `shared_browser_acquire_control`, `shared_browser_release_control`,
@@ -180,5 +184,5 @@ persistence, and archive teardown.
180
184
  Release Please maintains the version, changelog, component tag, and GitHub release from
181
185
  Conventional Commits in the monorepo.
182
186
 
183
- Both the Paseo daemon and app must satisfy `^0.9.0 || ^0.10.0`. The client surface uses React Native primitives
187
+ Both the Paseo daemon and app must satisfy `^0.9.0 || ^0.10.0 || ^0.11.0`. The client surface uses React Native primitives
184
188
  and works in desktop, web, iOS, and Android Paseo clients.
@@ -29,6 +29,8 @@ import {
29
29
  type BrowserInputEvent,
30
30
  type BrowserState,
31
31
  captureBrowserRpc,
32
+ containedRect,
33
+ createImageSizeLoader,
32
34
  DEVICE_PRESETS,
33
35
  type DevicePresetId,
34
36
  detachBrowserRpc,
@@ -41,7 +43,9 @@ import {
41
43
  releaseControlRpc,
42
44
  resizeBrowserRpc,
43
45
  sendBrowserInputRpc,
46
+ toViewportPoint,
44
47
  } from "../shared/browser";
48
+ import { FrameLifecycle } from "./frame-lifecycle";
45
49
 
46
50
  const SPACE = {
47
51
  xxs: 2,
@@ -88,12 +92,7 @@ interface Size {
88
92
  height: number;
89
93
  }
90
94
 
91
- interface DisplayRect extends Size {
92
- x: number;
93
- y: number;
94
- }
95
-
96
- interface DisplayPoint extends Size {
95
+ interface DisplayPoint {
97
96
  x: number;
98
97
  y: number;
99
98
  }
@@ -115,21 +114,6 @@ const SPECIAL_KEYS: readonly { key: SpecialKey; label: string }[] = [
115
114
  { key: "Space", label: "Space" },
116
115
  ];
117
116
 
118
- function containedRect(container: Size, image: Size): DisplayRect | null {
119
- if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
120
- return null;
121
- }
122
- const scale = Math.min(container.width / image.width, container.height / image.height);
123
- const width = image.width * scale;
124
- const height = image.height * scale;
125
- return {
126
- x: (container.width - width) / 2,
127
- y: (container.height - height) / 2,
128
- width,
129
- height,
130
- };
131
- }
132
-
133
117
  function errorMessage(error: unknown): string {
134
118
  if (error instanceof Error && error.message.trim()) return error.message;
135
119
  return "The shared browser request failed.";
@@ -1006,14 +990,15 @@ export function SharedBrowserPanel({
1006
990
  const mountedRef = useRef(false);
1007
991
  const activeViewerTokenRef = useRef<string | null>(null);
1008
992
  const stateRef = useRef<BrowserState | null>(null);
1009
- const frameRef = useRef<BrowserFrame | null>(null);
993
+ const lifecycleRef = useRef(new FrameLifecycle());
994
+ const lifecycle = lifecycleRef.current;
1010
995
  const lastPointRef = useRef<{ x: number; y: number } | null>(null);
1011
996
  const dragStartRef = useRef<{ x: number; y: number } | null>(null);
1012
- const mutationEpochRef = useRef(0);
1013
997
  const captureInFlightRef = useRef(false);
1014
998
 
1015
999
  const [state, setState] = useState<BrowserState | null>(null);
1016
1000
  const [frame, setFrame] = useState<BrowserFrame | null>(null);
1001
+ const [inputBusy, setInputBusy] = useState(false);
1017
1002
  const [controlToken, setControlToken] = useState<string | null>(null);
1018
1003
  const [operationError, setOperationError] = useState<string | null>(null);
1019
1004
  const [imageError, setImageError] = useState(false);
@@ -1041,7 +1026,7 @@ export function SharedBrowserPanel({
1041
1026
  const acceptState = useCallback((next: BrowserState) => {
1042
1027
  const previous = stateRef.current;
1043
1028
  if (previous && !isBrowserStateCurrent(previous, next)) return false;
1044
- const currentFrame = frameRef.current;
1029
+ const currentFrame = lifecycle.frame;
1045
1030
  if (previous && didBrowserRuntimeRestart(previous, next)) {
1046
1031
  setRuntimeNotice(
1047
1032
  "Browser restarted. The preserved viewer connection now targets the new runtime.",
@@ -1049,7 +1034,7 @@ export function SharedBrowserPanel({
1049
1034
  setControlToken(null);
1050
1035
  }
1051
1036
  if (currentFrame && !isFrameCurrent(currentFrame, next)) {
1052
- frameRef.current = null;
1037
+ lifecycle.drop();
1053
1038
  setFrame(null);
1054
1039
  lastPointRef.current = null;
1055
1040
  }
@@ -1091,9 +1076,10 @@ export function SharedBrowserPanel({
1091
1076
 
1092
1077
  useEffect(() => {
1093
1078
  stateRef.current = null;
1094
- frameRef.current = null;
1079
+ lifecycle.reset();
1095
1080
  setState(null);
1096
1081
  setFrame(null);
1082
+ setInputBusy(false);
1097
1083
  setControlToken(null);
1098
1084
  setOperationError(null);
1099
1085
  setRuntimeNotice(null);
@@ -1103,8 +1089,8 @@ export function SharedBrowserPanel({
1103
1089
  queryKey: ["shared-browser", "capture", viewerToken],
1104
1090
  queryFn: async () => {
1105
1091
  if (!viewerToken) throw new Error("The browser viewer is not attached.");
1106
- const mutationEpoch = mutationEpochRef.current;
1107
- const knownFrame = frameRef.current;
1092
+ const mutationEpoch = lifecycle.epoch;
1093
+ const knownFrame = lifecycle.frame;
1108
1094
  captureInFlightRef.current = true;
1109
1095
  try {
1110
1096
  const result = await captureBrowser({
@@ -1130,17 +1116,14 @@ export function SharedBrowserPanel({
1130
1116
 
1131
1117
  useEffect(() => {
1132
1118
  const result = captureQuery.data;
1133
- if (
1134
- !result ||
1135
- result.mutationEpoch !== mutationEpochRef.current ||
1136
- !acceptState(result.state)
1137
- ) {
1119
+ if (!result || !lifecycle.isCurrent(result.mutationEpoch) || !acceptState(result.state)) {
1138
1120
  return;
1139
1121
  }
1140
1122
  if (result.frame && isFrameCurrent(result.frame, result.state)) {
1141
- frameRef.current = result.frame;
1123
+ lifecycle.accept(result.mutationEpoch, result.frame);
1142
1124
  setImageError(false);
1143
- setFrame(result.frame);
1125
+ setFrame(lifecycle.visible);
1126
+ setInputBusy(lifecycle.busy);
1144
1127
  }
1145
1128
  }, [acceptState, captureQuery.data]);
1146
1129
 
@@ -1229,6 +1212,9 @@ export function SharedBrowserPanel({
1229
1212
  onSuccess: (result) => mutationSucceeded(result.state),
1230
1213
  onError: mutationFailed,
1231
1214
  });
1215
+ const settleInput = () => {
1216
+ lifecycle.settle();
1217
+ };
1232
1218
  const inputMutation = useMutation({
1233
1219
  mutationFn: sendBrowserInput,
1234
1220
  retry: false,
@@ -1237,9 +1223,13 @@ export function SharedBrowserPanel({
1237
1223
  const sentText = variables.event.text;
1238
1224
  setTypeDraft((current) => (current === sentText ? "" : current));
1239
1225
  }
1226
+ settleInput();
1240
1227
  mutationSucceeded(result.state);
1241
1228
  },
1242
- onError: mutationFailed,
1229
+ onError: (error) => {
1230
+ settleInput();
1231
+ mutationFailed(error);
1232
+ },
1243
1233
  });
1244
1234
 
1245
1235
  const anyMutationPending =
@@ -1251,18 +1241,37 @@ export function SharedBrowserPanel({
1251
1241
  inputMutation.isPending;
1252
1242
  const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1253
1243
  const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1254
- const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
1244
+ const canSendInput =
1245
+ canControl && Boolean(currentFrame) && !inputMutation.isPending && !inputBusy;
1255
1246
 
1247
+ const frameUri = useMemo(
1248
+ () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1249
+ [currentFrame],
1250
+ );
1251
+ const [imageSize, setImageSize] = useState<{ uri: string; size: Size } | null>(null);
1252
+ const loadImageSize = useMemo(
1253
+ () =>
1254
+ createImageSizeLoader(
1255
+ (uri, success, failure) => Image.getSize(uri, success, failure),
1256
+ (uri, size) => setImageSize(size ? { uri, size } : null),
1257
+ ),
1258
+ [],
1259
+ );
1260
+ useEffect(() => {
1261
+ if (frameUri) loadImageSize(frameUri);
1262
+ }, [frameUri, loadImageSize]);
1263
+ // The bitmap can disagree with the frame metadata; its own aspect ratio decides the rectangle.
1256
1264
  const displayRect = useMemo(
1257
1265
  () =>
1258
1266
  currentFrame
1259
- ? containedRect(containerSize, { width: currentFrame.width, height: currentFrame.height })
1267
+ ? containedRect(
1268
+ containerSize,
1269
+ imageSize?.uri === frameUri
1270
+ ? imageSize.size
1271
+ : { width: currentFrame.width, height: currentFrame.height },
1272
+ )
1260
1273
  : null,
1261
- [containerSize, currentFrame],
1262
- );
1263
- const frameUri = useMemo(
1264
- () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
1265
- [currentFrame],
1274
+ [containerSize, currentFrame, frameUri, imageSize],
1266
1275
  );
1267
1276
 
1268
1277
  const controlContext = useCallback(() => {
@@ -1283,7 +1292,7 @@ export function SharedBrowserPanel({
1283
1292
  const inputContext = useCallback(() => {
1284
1293
  const context = controlContext();
1285
1294
  const current = stateRef.current;
1286
- const targetFrame = frameRef.current;
1295
+ const targetFrame = lifecycle.frame;
1287
1296
  if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
1288
1297
  return {
1289
1298
  ...context,
@@ -1316,12 +1325,13 @@ export function SharedBrowserPanel({
1316
1325
 
1317
1326
  const sendEvent = useCallback(
1318
1327
  (event: BrowserInputEvent) => {
1328
+ if (lifecycle.busy) return;
1319
1329
  const context = requireInputContext();
1320
- if (!context || inputMutation.isPending) return;
1321
- mutationEpochRef.current += 1;
1330
+ if (!context || !lifecycle.begin()) return;
1331
+ setInputBusy(true);
1322
1332
  inputMutation.mutate({ ...context, event });
1323
1333
  },
1324
- [inputMutation, requireInputContext],
1334
+ [inputMutation, lifecycle, requireInputContext],
1325
1335
  );
1326
1336
 
1327
1337
  const pointFromEvent = useCallback(
@@ -1329,7 +1339,7 @@ export function SharedBrowserPanel({
1329
1339
  if (!displayRect) return null;
1330
1340
  const x = Math.min(displayRect.width, Math.max(0, event.nativeEvent.locationX));
1331
1341
  const y = Math.min(displayRect.height, Math.max(0, event.nativeEvent.locationY));
1332
- return { x, y, width: displayRect.width, height: displayRect.height };
1342
+ return { x, y };
1333
1343
  },
1334
1344
  [displayRect],
1335
1345
  );
@@ -1338,17 +1348,18 @@ export function SharedBrowserPanel({
1338
1348
  (event: GestureResponderEvent) => {
1339
1349
  const start = dragStartRef.current;
1340
1350
  const end = pointFromEvent(event);
1351
+ const viewport = stateRef.current?.viewport;
1341
1352
  dragStartRef.current = null;
1342
- if (!start || !end) return;
1353
+ if (!start || !end || !displayRect || !viewport) return;
1354
+ const toViewport = (point: DisplayPoint) => toViewportPoint(point, displayRect, viewport);
1343
1355
  lastPointRef.current = { x: end.x, y: end.y };
1344
1356
  const distance = Math.hypot(end.x - start.x, end.y - start.y);
1345
1357
  if (distance >= DRAG_THRESHOLD) {
1346
1358
  if (swipeMode === "scroll") {
1347
- const current = stateRef.current;
1348
- const scale = current ? current.viewport.width / end.width : 1;
1359
+ const scale = viewport.width / displayRect.width;
1349
1360
  sendEvent({
1350
1361
  kind: "scroll",
1351
- point: { ...start, width: end.width, height: end.height },
1362
+ point: toViewport(start),
1352
1363
  deltaX: clampScrollDelta((start.x - end.x) * scale),
1353
1364
  deltaY: clampScrollDelta((start.y - end.y) * scale),
1354
1365
  });
@@ -1356,20 +1367,20 @@ export function SharedBrowserPanel({
1356
1367
  }
1357
1368
  sendEvent({
1358
1369
  kind: "drag",
1359
- start: { ...start, width: end.width, height: end.height },
1360
- end,
1370
+ start: toViewport(start),
1371
+ end: toViewport(end),
1361
1372
  button: interactionMode === "right" ? "right" : "left",
1362
1373
  });
1363
1374
  return;
1364
1375
  }
1365
1376
  sendEvent({
1366
1377
  kind: "click",
1367
- point: end,
1378
+ point: toViewport(end),
1368
1379
  button: interactionMode === "right" ? "right" : "left",
1369
1380
  clickCount: interactionMode === "double" ? 2 : 1,
1370
1381
  });
1371
1382
  },
1372
- [interactionMode, pointFromEvent, sendEvent, swipeMode],
1383
+ [displayRect, interactionMode, pointFromEvent, sendEvent, swipeMode],
1373
1384
  );
1374
1385
 
1375
1386
  const panResponder = useMemo(
@@ -1400,7 +1411,7 @@ export function SharedBrowserPanel({
1400
1411
  const takeControl = useCallback(
1401
1412
  (takeover: boolean) => {
1402
1413
  if (!viewerToken || acquireMutation.isPending) return;
1403
- mutationEpochRef.current += 1;
1414
+ lifecycle.bump();
1404
1415
  acquireMutation.mutate({ viewerToken, takeover });
1405
1416
  },
1406
1417
  [acquireMutation, viewerToken],
@@ -1415,7 +1426,7 @@ export function SharedBrowserPanel({
1415
1426
  ) {
1416
1427
  return;
1417
1428
  }
1418
- mutationEpochRef.current += 1;
1429
+ lifecycle.bump();
1419
1430
  releaseMutation.mutate({ viewerToken: viewer, controlToken });
1420
1431
  }, [controlToken, releaseMutation]);
1421
1432
 
@@ -1429,11 +1440,11 @@ export function SharedBrowserPanel({
1429
1440
  setOperationError("Enter an address to navigate.");
1430
1441
  return;
1431
1442
  }
1432
- mutationEpochRef.current += 1;
1443
+ lifecycle.bump();
1433
1444
  navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1434
1445
  return;
1435
1446
  }
1436
- mutationEpochRef.current += 1;
1447
+ lifecycle.bump();
1437
1448
  navigateMutation.mutate({ ...context, action: { kind: action } });
1438
1449
  },
1439
1450
  [navigateMutation, requireControlContext],
@@ -1457,7 +1468,7 @@ export function SharedBrowserPanel({
1457
1468
  );
1458
1469
  return;
1459
1470
  }
1460
- mutationEpochRef.current += 1;
1471
+ lifecycle.bump();
1461
1472
  resizeMutation.mutate({ ...context, viewport: { width, height } });
1462
1473
  }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1463
1474
 
@@ -1465,7 +1476,7 @@ export function SharedBrowserPanel({
1465
1476
  (presetId: DevicePresetId) => {
1466
1477
  const context = requireControlContext();
1467
1478
  if (!context || deviceMutation.isPending) return;
1468
- mutationEpochRef.current += 1;
1479
+ lifecycle.bump();
1469
1480
  setDevicePickerOpen(false);
1470
1481
  deviceMutation.mutate({ ...context, presetId });
1471
1482
  },
@@ -1474,13 +1485,14 @@ export function SharedBrowserPanel({
1474
1485
 
1475
1486
  const scroll = useCallback(
1476
1487
  (deltaX: number, deltaY: number) => {
1477
- if (!displayRect) return;
1488
+ const viewport = stateRef.current?.viewport;
1489
+ if (!displayRect || !viewport) return;
1478
1490
  const previous = lastPointRef.current;
1479
1491
  const x = previous && previous.x <= displayRect.width ? previous.x : displayRect.width / 2;
1480
1492
  const y = previous && previous.y <= displayRect.height ? previous.y : displayRect.height / 2;
1481
1493
  sendEvent({
1482
1494
  kind: "scroll",
1483
- point: { x, y, width: displayRect.width, height: displayRect.height },
1495
+ point: toViewportPoint({ x, y }, displayRect, viewport),
1484
1496
  deltaX,
1485
1497
  deltaY,
1486
1498
  });
@@ -1495,7 +1507,7 @@ export function SharedBrowserPanel({
1495
1507
 
1496
1508
  const reconnect = useCallback(() => {
1497
1509
  if (attachQuery.isFetching) return;
1498
- mutationEpochRef.current += 1;
1510
+ lifecycle.bump();
1499
1511
  setReconnecting(true);
1500
1512
  setControlToken(null);
1501
1513
  setOperationError(null);
@@ -1700,7 +1712,7 @@ export function SharedBrowserPanel({
1700
1712
  }
1701
1713
  accessibilityRole="image"
1702
1714
  onError={() => {
1703
- frameRef.current = null;
1715
+ lifecycle.frame = null;
1704
1716
  setImageError(true);
1705
1717
  void captureQuery.refetch({ cancelRefetch: false });
1706
1718
  }}
@@ -0,0 +1,65 @@
1
+ import type { BrowserFrame } from "../shared/browser";
2
+
3
+ /**
4
+ * Tracks which captured frame input may target. After an input is submitted
5
+ * and again when it settles, the actionable frame is dropped and the epoch
6
+ * advances, so captures that began earlier are ignored. Input stays blocked
7
+ * until a capture taken after settlement is accepted. The last accepted image
8
+ * stays visible meanwhile.
9
+ */
10
+ export class FrameLifecycle {
11
+ epoch = 0;
12
+ /** Frame input may target; null while a fresh capture is awaited. */
13
+ frame: BrowserFrame | null = null;
14
+ /** Last accepted frame, kept on screen while input is blocked. */
15
+ visible: BrowserFrame | null = null;
16
+ private gate: "idle" | "pending" | "refreshing" = "idle";
17
+
18
+ get busy(): boolean {
19
+ return this.gate !== "idle";
20
+ }
21
+
22
+ bump(): void {
23
+ this.epoch += 1;
24
+ }
25
+
26
+ isCurrent(captureEpoch: number): boolean {
27
+ return captureEpoch === this.epoch;
28
+ }
29
+
30
+ /** Claims the input slot; false if an input is already in flight or awaiting a fresh frame. */
31
+ begin(): boolean {
32
+ if (this.busy) return false;
33
+ this.gate = "pending";
34
+ this.frame = null;
35
+ this.bump();
36
+ return true;
37
+ }
38
+
39
+ /** Called when an input succeeds or fails. Never replays the action. */
40
+ settle(): void {
41
+ this.frame = null;
42
+ this.bump();
43
+ this.gate = "refreshing";
44
+ }
45
+
46
+ /** Accepts a capture started at `captureEpoch`; false if it predates the latest boundary. */
47
+ accept(captureEpoch: number, frame: BrowserFrame): boolean {
48
+ if (!this.isCurrent(captureEpoch)) return false;
49
+ this.frame = frame;
50
+ this.visible = frame;
51
+ if (this.gate === "refreshing") this.gate = "idle";
52
+ return true;
53
+ }
54
+
55
+ /** Forgets all frames, e.g. when the session no longer matches them. */
56
+ drop(): void {
57
+ this.frame = null;
58
+ this.visible = null;
59
+ }
60
+
61
+ reset(): void {
62
+ this.drop();
63
+ this.gate = "idle";
64
+ }
65
+ }
@@ -0,0 +1,134 @@
1
+ import {
2
+ type PluginClientContext,
3
+ type PluginPopoverProps,
4
+ type PluginSidebarItemProps,
5
+ useRpc,
6
+ useWorkspace,
7
+ } from "@getpaseo/plugin/client";
8
+ import { SidebarRow } from "@getpaseo/plugin/client/ui";
9
+ import { useQuery } from "@tanstack/react-query";
10
+ import { useMemo } from "react";
11
+ import { Pressable, StyleSheet, Text, View } from "react-native";
12
+ import { listOpenBrowserWorkspacesRpc } from "../shared/browser";
13
+
14
+ const PANEL_ID = "shared-browser";
15
+ const PRESENCE_POLL_MS = 5_000;
16
+ const TOUCH_TARGET = 44;
17
+ const ROW_HEIGHT = 36;
18
+ const RADIUS = 8;
19
+
20
+ function useOpenBrowserWorkspaceIds(): readonly string[] {
21
+ const listOpen = useRpc(listOpenBrowserWorkspacesRpc);
22
+ const query = useQuery({
23
+ queryKey: ["shared-browser", "presence"],
24
+ queryFn: async () => (await listOpen({})).workspaceIds,
25
+ refetchInterval: PRESENCE_POLL_MS,
26
+ retry: false,
27
+ });
28
+ return query.data ?? [];
29
+ }
30
+
31
+ function SessionRow({
32
+ workspaceId,
33
+ theme,
34
+ compact,
35
+ onPress,
36
+ }: {
37
+ workspaceId: string;
38
+ theme: PluginPopoverProps["theme"];
39
+ compact: boolean;
40
+ onPress(): void;
41
+ }) {
42
+ const name = useWorkspace(workspaceId, (workspace) => workspace.title ?? workspace.name);
43
+ const styles = useMemo(
44
+ () =>
45
+ StyleSheet.create({
46
+ row: {
47
+ minHeight: compact ? TOUCH_TARGET : ROW_HEIGHT,
48
+ justifyContent: "center",
49
+ paddingHorizontal: 12,
50
+ borderRadius: RADIUS,
51
+ },
52
+ label: { color: theme.colors.foreground, fontSize: 14 },
53
+ }),
54
+ [theme, compact],
55
+ );
56
+ // A workspace this client has not loaded cannot host the panel; openPanel would throw.
57
+ if (name === null) return null;
58
+ return (
59
+ <Pressable
60
+ accessibilityRole="button"
61
+ accessibilityLabel={`Open Shared Browser for ${name}`}
62
+ onPress={onPress}
63
+ style={({ pressed }) => [
64
+ styles.row,
65
+ pressed ? { backgroundColor: theme.colors.surface1 } : null,
66
+ ]}
67
+ >
68
+ <Text numberOfLines={1} style={styles.label}>
69
+ {name}
70
+ </Text>
71
+ </Pressable>
72
+ );
73
+ }
74
+
75
+ function createSessionsPopover(client: PluginClientContext) {
76
+ return function SessionsPopover({ theme, layout, close }: PluginPopoverProps) {
77
+ const workspaceIds = useOpenBrowserWorkspaceIds();
78
+ const styles = useMemo(
79
+ () =>
80
+ StyleSheet.create({
81
+ container: { padding: layout.compact ? 16 : 8, gap: 4, minWidth: 220 },
82
+ title: {
83
+ color: theme.colors.foregroundMuted,
84
+ fontSize: 12,
85
+ paddingHorizontal: 12,
86
+ paddingBottom: 4,
87
+ },
88
+ }),
89
+ [theme, layout.compact],
90
+ );
91
+ return (
92
+ <View style={styles.container}>
93
+ <Text style={styles.title}>Open browser sessions</Text>
94
+ {workspaceIds.map((workspaceId) => (
95
+ <SessionRow
96
+ key={workspaceId}
97
+ workspaceId={workspaceId}
98
+ theme={theme}
99
+ compact={layout.compact}
100
+ onPress={() => {
101
+ close();
102
+ client.openPanel(PANEL_ID, { workspaceId });
103
+ }}
104
+ />
105
+ ))}
106
+ </View>
107
+ );
108
+ };
109
+ }
110
+
111
+ function createSidebarItem(client: PluginClientContext) {
112
+ const SessionsPopover = createSessionsPopover(client);
113
+ return function SharedBrowserSidebarItem({ openPopover }: PluginSidebarItemProps) {
114
+ const count = useOpenBrowserWorkspaceIds().length;
115
+ if (count === 0) return null;
116
+ return (
117
+ <SidebarRow
118
+ icon="PanelsTopLeft"
119
+ label={`Shared Browser (${count})`}
120
+ onPress={() => openPopover(SessionsPopover)}
121
+ />
122
+ );
123
+ };
124
+ }
125
+
126
+ /** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
127
+ export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
128
+ if (typeof client.addSidebarFooterItem !== "function") return () => {};
129
+ return client.addSidebarFooterItem({
130
+ id: "shared-browser-sessions",
131
+ title: "Shared Browser",
132
+ Component: createSidebarItem(client),
133
+ });
134
+ }
package/index.client.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
2
  import { contributeSharedBrowserClient, SharedBrowserPanel } from "./client/browser";
3
+ import { contributeSharedBrowserSidebar } from "./client/sidebar";
3
4
 
4
5
  export default function contribute(client: PluginClientContext) {
5
6
  client.addWorkspacePanel({
@@ -31,5 +32,10 @@ export default function contribute(client: PluginClientContext) {
31
32
  },
32
33
  });
33
34
 
34
- return contributeSharedBrowserClient(client);
35
+ const removeSidebar = contributeSharedBrowserSidebar(client);
36
+ const cleanup = contributeSharedBrowserClient(client);
37
+ return () => {
38
+ removeSidebar();
39
+ return cleanup();
40
+ };
35
41
  }
package/index.server.ts CHANGED
@@ -77,8 +77,12 @@ export default function contribute(server: PluginServerContext) {
77
77
  server.handle(resizeBrowserRpc, handleResizeBrowser);
78
78
  server.handle(applyDevicePresetRpc, handleApplyDevicePreset);
79
79
  server.handle(sendBrowserInputRpc, handleSendBrowserInput);
80
+ // OMP accepts session MCP servers from Paseo 0.11.0-beta.1, the release that added
81
+ // registerUsageSource; 0.9/0.10 reject agent creation when an OMP config carries MCP servers.
82
+ const ompAcceptsMcp = typeof server.registerUsageSource === "function";
80
83
  server.before("agent.create", async ({ request }) => {
81
- if (request.config.internal || request.config.provider === "omp") return request;
84
+ if (request.config.internal) return request;
85
+ if (request.config.provider === "omp" && !ompAcceptsMcp) return request;
82
86
  const ticket = randomBytes(32).toString("base64url");
83
87
  if (!(await issueTicketWithinDeadline(ticket))) return request;
84
88
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.1.0",
3
+ "version": "1.2.0-next.214.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
@@ -43,12 +43,12 @@
43
43
  "check:write": "biome check --write .",
44
44
  "typecheck": "tsc --noEmit",
45
45
  "prepare:runtime": "node scripts/prepare-runtime.mjs",
46
- "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",
47
- "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",
46
+ "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 tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts",
47
+ "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 tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts",
48
48
  "test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
49
49
  },
50
50
  "dependencies": {
51
- "@getpaseo/plugin": "0.10.1",
51
+ "@getpaseo/plugin": "0.11.0-beta.3",
52
52
  "@modelcontextprotocol/sdk": "1.30.0",
53
53
  "agent-browser": "0.37.1",
54
54
  "esbuild": "0.28.2",
package/paseo-plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "shared-browser",
3
3
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
4
- "requirements": { "paseo": "^0.9.0 || ^0.10.0" },
4
+ "requirements": { "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0" },
5
5
  "build": [["node", "scripts/prepare-dependencies.mjs"], ["npm", "run", "prepare:runtime"]]
6
6
  }
@@ -183,6 +183,26 @@ function findString(value: unknown, keys: readonly string[]): string | null {
183
183
  return null;
184
184
  }
185
185
 
186
+ export function jpegDimensions(base64: string): { width: number; height: number } | null {
187
+ const bytes = Buffer.from(base64, "base64");
188
+ let offset = 2;
189
+ while (bytes[0] === 0xff && bytes[1] === 0xd8 && offset + 9 <= bytes.length) {
190
+ if (bytes[offset] !== 0xff) return null;
191
+ const marker = bytes[offset + 1];
192
+ // SOFn carries the frame size; DHT (c4), JPG (c8) and DAC (cc) share the range but do not.
193
+ if (
194
+ marker !== undefined &&
195
+ marker >= 0xc0 &&
196
+ marker <= 0xcf &&
197
+ ![0xc4, 0xc8, 0xcc].includes(marker)
198
+ )
199
+ return { width: bytes.readUInt16BE(offset + 7), height: bytes.readUInt16BE(offset + 5) };
200
+ if (marker === 0xda) return null;
201
+ offset += 2 + bytes.readUInt16BE(offset + 2);
202
+ }
203
+ return null;
204
+ }
205
+
186
206
  export class AgentBrowserRuntime {
187
207
  readonly binaryPath: string;
188
208
  readonly executablePath: string;
@@ -318,7 +338,7 @@ export class AgentBrowserRuntime {
318
338
  await this.requireConnection().send("Target.activateTarget", { targetId }, { mutation: true });
319
339
  this.page = await attachToTarget(this.requireConnection(), targetId);
320
340
  this.targetId = targetId;
321
- await this.bindPageEvents(this.page);
341
+ await this.preparePage(this.page);
322
342
  if (this.screencastActive) await this.startScreencastSession(this.page);
323
343
  }
324
344
 
@@ -425,34 +445,7 @@ export class AgentBrowserRuntime {
425
445
  async emulate(device: DeviceEmulation): Promise<void> {
426
446
  this.assertViewport(device);
427
447
  await this.withInvalidatedScreencast(async (page) => {
428
- await page.send(
429
- "Emulation.setDeviceMetricsOverride",
430
- {
431
- width: device.width,
432
- height: device.height,
433
- deviceScaleFactor: device.deviceScaleFactor,
434
- mobile: device.mobile,
435
- screenWidth: device.screenWidth ?? device.width,
436
- screenHeight: device.screenHeight ?? device.height,
437
- screenOrientation: {
438
- type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
439
- angle: device.width > device.height ? 90 : 0,
440
- },
441
- },
442
- { mutation: true },
443
- );
444
- await page.send(
445
- "Emulation.setTouchEmulationEnabled",
446
- { enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
447
- { mutation: true },
448
- );
449
- if (device.userAgent) {
450
- await page.send(
451
- "Emulation.setUserAgentOverride",
452
- { userAgent: device.userAgent, platform: device.platform ?? "" },
453
- { mutation: true },
454
- );
455
- }
448
+ await this.applyEmulation(page, device);
456
449
  this.viewport = { ...device };
457
450
  });
458
451
  }
@@ -483,7 +476,17 @@ export class AgentBrowserRuntime {
483
476
  throw new RangeError("maxBytes must be positive");
484
477
  const streamed = this.screencastFrame ?? (await this.waitForFrame(waitMs));
485
478
  if (streamed && streamed.byteLength <= maxBytes) return streamed;
486
- const page = await this.requirePage();
479
+ let frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
480
+ if (!this.sizeMismatch(frame)) return frame;
481
+ // The browser stopped honouring the cached viewport. Re-apply it once; never loop.
482
+ await this.emulate(this.requireViewport());
483
+ frame = await this.screenshot(await this.requirePage(), maxBytes, quality);
484
+ const mismatch = this.sizeMismatch(frame);
485
+ if (mismatch) throw new Error(`Browser returned a ${mismatch} after viewport resync`);
486
+ return frame;
487
+ }
488
+
489
+ private async screenshot(page: CdpSession, maxBytes: number, quality: number) {
487
490
  for (const candidate of [quality, 50, 35, 20, 10, 1]) {
488
491
  const boundedQuality = Math.max(1, Math.min(100, Math.round(candidate)));
489
492
  const result = await page.send<{ data: string }>("Page.captureScreenshot", {
@@ -502,12 +505,25 @@ export class AgentBrowserRuntime {
502
505
  height: viewport.height,
503
506
  transport: "screenshot",
504
507
  capturedAt: new Date().toISOString(),
505
- };
508
+ } satisfies RuntimeFrame;
506
509
  }
507
510
  }
508
511
  throw new Error(`JPEG screenshot exceeds ${maxBytes} bytes at minimum quality`);
509
512
  }
510
513
 
514
+ private sizeMismatch(frame: RuntimeFrame): string | null {
515
+ const { width, height, deviceScaleFactor: scale } = this.requireViewport();
516
+ const size = jpegDimensions(frame.dataBase64);
517
+ // Chromium rounds fractional device pixels, so allow one pixel of slack.
518
+ if (
519
+ size &&
520
+ Math.abs(size.width - width * scale) <= 1 &&
521
+ Math.abs(size.height - height * scale) <= 1
522
+ )
523
+ return null;
524
+ return `${size ? `${size.width}x${size.height}` : "unreadable"} JPEG for a ${width}x${height} viewport at ${scale}x`;
525
+ }
526
+
511
527
  async mouseMove(x: number, y: number): Promise<void> {
512
528
  this.assertPoint(x, y);
513
529
  await (await this.requirePage()).send(
@@ -786,13 +802,46 @@ export class AgentBrowserRuntime {
786
802
  return this.viewport;
787
803
  }
788
804
 
789
- private async bindPageEvents(page: CdpSession): Promise<void> {
805
+ private async preparePage(page: CdpSession): Promise<void> {
790
806
  page.on("Page.screencastFrame", this.onScreencastFrame);
791
807
  page.on("event", (event: CdpEvent) => {
792
808
  if (event.method === "Inspector.targetCrashed") this.invalidateScreencastFrame();
793
809
  });
794
810
  await Promise.all([page.send("Page.enable"), page.send("Runtime.enable")]);
795
811
  await this.navigationHistory(page);
812
+ // Emulation is scoped to the CDP session, so every fresh attach starts without it.
813
+ if (this.viewport) await this.applyEmulation(page, this.viewport);
814
+ }
815
+
816
+ private async applyEmulation(page: CdpSession, device: DeviceEmulation): Promise<void> {
817
+ await page.send(
818
+ "Emulation.setDeviceMetricsOverride",
819
+ {
820
+ width: device.width,
821
+ height: device.height,
822
+ deviceScaleFactor: device.deviceScaleFactor,
823
+ mobile: device.mobile,
824
+ screenWidth: device.screenWidth ?? device.width,
825
+ screenHeight: device.screenHeight ?? device.height,
826
+ screenOrientation: {
827
+ type: device.width > device.height ? "landscapePrimary" : "portraitPrimary",
828
+ angle: device.width > device.height ? 90 : 0,
829
+ },
830
+ },
831
+ { mutation: true },
832
+ );
833
+ await page.send(
834
+ "Emulation.setTouchEmulationEnabled",
835
+ { enabled: device.touch, maxTouchPoints: device.touch ? 5 : 1 },
836
+ { mutation: true },
837
+ );
838
+ if (device.userAgent) {
839
+ await page.send(
840
+ "Emulation.setUserAgentOverride",
841
+ { userAgent: device.userAgent, platform: device.platform ?? "" },
842
+ { mutation: true },
843
+ );
844
+ }
796
845
  }
797
846
 
798
847
  private async startScreencastSession(page: CdpSession): Promise<void> {
@@ -837,7 +886,7 @@ export class AgentBrowserRuntime {
837
886
  { mutation: true },
838
887
  );
839
888
  const replacement = await attachToTarget(connection, target.targetId);
840
- await this.bindPageEvents(replacement);
889
+ await this.preparePage(replacement);
841
890
  if (this.page === previous) {
842
891
  this.page = replacement;
843
892
  this.targetId = target.targetId;
package/shared/browser.ts CHANGED
@@ -365,3 +365,70 @@ export function mapDisplayedPoint(
365
365
  y: Math.min(viewport.height - 1, Math.max(0, (point.y / point.height) * viewport.height)),
366
366
  };
367
367
  }
368
+
369
+ export interface FrameSize {
370
+ width: number;
371
+ height: number;
372
+ }
373
+
374
+ export interface FrameRect extends FrameSize {
375
+ x: number;
376
+ y: number;
377
+ }
378
+
379
+ export function containedRect(container: FrameSize, image: FrameSize): FrameRect | null {
380
+ if (container.width <= 0 || container.height <= 0 || image.width <= 0 || image.height <= 0) {
381
+ return null;
382
+ }
383
+ const scale = Math.min(container.width / image.width, container.height / image.height);
384
+ const width = image.width * scale;
385
+ const height = image.height * scale;
386
+ return {
387
+ x: (container.width - width) / 2,
388
+ y: (container.height - height) / 2,
389
+ width,
390
+ height,
391
+ };
392
+ }
393
+
394
+ /** Converts a point in the displayed image to the canonical viewport, scaling both axes by viewport.width / rect.width. */
395
+ export function toViewportPoint(
396
+ point: MappedPoint,
397
+ rect: FrameSize,
398
+ viewport: Viewport,
399
+ ): MappedPoint & FrameSize {
400
+ const scale = viewport.width / rect.width;
401
+ return {
402
+ x: point.x * scale,
403
+ y: Math.min(viewport.height, point.y * scale),
404
+ width: viewport.width,
405
+ height: viewport.height,
406
+ };
407
+ }
408
+
409
+ type GetImageSize = (
410
+ uri: string,
411
+ success: (width: number, height: number) => void,
412
+ failure: () => void,
413
+ ) => void;
414
+
415
+ /** Looks up the intrinsic size once per distinct URI and drops results superseded by a newer URI. */
416
+ export function createImageSizeLoader(
417
+ getSize: GetImageSize,
418
+ onSize: (uri: string, size: FrameSize | null) => void,
419
+ ): (uri: string) => void {
420
+ let current: string | null = null;
421
+ return (uri) => {
422
+ if (uri === current) return;
423
+ current = uri;
424
+ getSize(
425
+ uri,
426
+ (width, height) => {
427
+ if (current === uri) onSize(uri, { width, height });
428
+ },
429
+ () => {
430
+ if (current === uri) onSize(uri, null);
431
+ },
432
+ );
433
+ };
434
+ }