@omercnet/paseo-shared-browser 1.2.2 → 2.0.0-next.244.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.
Files changed (173) hide show
  1. package/OVERVIEW.md +2 -2
  2. package/README.md +316 -27
  3. package/REMOTE_BROWSER_RESEARCH.md +395 -0
  4. package/STREAMING_DESIGN.md +196 -0
  5. package/client/browser-auto-capture-density.test.ts +102 -0
  6. package/client/browser-auto-capture-density.ts +30 -0
  7. package/client/browser-canvas-input.test.ts +198 -0
  8. package/client/browser-canvas-input.ts +202 -0
  9. package/client/browser-canvas-keyboard-web.test.ts +113 -0
  10. package/client/browser-canvas-keyboard-web.ts +230 -0
  11. package/client/browser-canvas-keyboard.test.ts +123 -0
  12. package/client/browser-canvas-keyboard.ts +126 -0
  13. package/client/browser-canvas-layout.test.ts +109 -0
  14. package/client/browser-canvas-layout.ts +51 -0
  15. package/client/browser-canvas-viewport-web.test.ts +77 -0
  16. package/client/browser-canvas-viewport-web.ts +63 -0
  17. package/client/browser-canvas-viewport.tsx +152 -0
  18. package/client/browser-capture-cadence.test.ts +9 -0
  19. package/client/browser-capture-cadence.ts +6 -0
  20. package/client/browser-capture-density-controls.tsx +75 -0
  21. package/client/browser-chrome.tsx +234 -0
  22. package/client/browser-client-presence.ts +184 -0
  23. package/client/browser-compose-controls.test.tsx +275 -0
  24. package/client/browser-compose-controls.tsx +174 -0
  25. package/client/browser-control-button.tsx +109 -0
  26. package/client/browser-control-dialog.tsx +71 -0
  27. package/client/browser-control-request.test.ts +27 -0
  28. package/client/browser-control-request.ts +57 -0
  29. package/client/browser-control-tooltip-web.ts +10 -0
  30. package/client/browser-dialog-body.tsx +22 -0
  31. package/client/browser-emulation-mode.test.ts +75 -0
  32. package/client/browser-emulation-mode.ts +98 -0
  33. package/client/browser-fill-viewport.test.ts +45 -0
  34. package/client/browser-fill-viewport.ts +28 -0
  35. package/client/browser-frame-buffer.test.ts +157 -0
  36. package/client/browser-frame-buffer.ts +148 -0
  37. package/client/browser-frame-image.tsx +62 -0
  38. package/client/browser-image-load.test.ts +23 -0
  39. package/client/browser-image-load.ts +26 -0
  40. package/client/browser-input-queue.test.ts +1254 -0
  41. package/client/browser-input-queue.ts +519 -0
  42. package/client/browser-menu-placement.test.ts +90 -0
  43. package/client/browser-menu-placement.ts +78 -0
  44. package/client/browser-panel-styles.ts +384 -0
  45. package/client/browser-quality-controls.test.ts +26 -0
  46. package/client/browser-quality-controls.tsx +139 -0
  47. package/client/browser-resolution-picker.tsx +189 -0
  48. package/client/browser-tabs.tsx +117 -0
  49. package/client/browser-toolbar-menu-web.test.ts +185 -0
  50. package/client/browser-toolbar-menu-web.ts +159 -0
  51. package/client/browser-toolbar-menu.tsx +255 -0
  52. package/client/browser-toolbar-submenu.tsx +93 -0
  53. package/client/browser-video-bytes.test.ts +44 -0
  54. package/client/browser-video-currency.ts +45 -0
  55. package/client/browser-video-decoder.test.ts +473 -0
  56. package/client/browser-video-decoder.ts +311 -0
  57. package/client/browser-video-surface.tsx +21 -0
  58. package/client/browser-video-visibility.test.ts +45 -0
  59. package/client/browser-viewer-recovery.test.ts +111 -0
  60. package/client/browser-viewer-recovery.ts +78 -0
  61. package/client/browser.tsx +1650 -1579
  62. package/client/favorite-star.tsx +23 -0
  63. package/client/frame-lifecycle.ts +62 -59
  64. package/client/sidebar.tsx +1 -2
  65. package/client/use-browser-auto-capture-density.test.ts +104 -0
  66. package/client/use-browser-auto-capture-density.ts +43 -0
  67. package/client/use-browser-canvas-input.ts +375 -0
  68. package/client/use-browser-capture-density.ts +87 -0
  69. package/client/use-browser-emulation-mode.ts +56 -0
  70. package/client/use-browser-fill-viewport.test.ts +108 -0
  71. package/client/use-browser-fill-viewport.ts +62 -0
  72. package/client/use-browser-frame-buffer.ts +66 -0
  73. package/client/use-browser-image-capture.ts +139 -0
  74. package/client/use-browser-scoped-mutation.test.ts +118 -0
  75. package/client/use-browser-scoped-mutation.ts +59 -0
  76. package/client/use-browser-video.test.ts +1060 -0
  77. package/client/use-browser-video.ts +481 -0
  78. package/client/use-browser-viewer-recovery.ts +26 -0
  79. package/client/use-resolution-favorites.ts +90 -0
  80. package/client/web.test.ts +125 -0
  81. package/client/web.ts +405 -0
  82. package/index.client.tsx +0 -10
  83. package/index.server.ts +32 -4
  84. package/npm-shrinkwrap.json +9 -9
  85. package/package.json +10 -5
  86. package/paseo-plugin.json +1 -1
  87. package/scripts/prepare-runtime.mjs +1 -1
  88. package/server/agent-browser-runtime.ts +1601 -314
  89. package/server/browser-cursor.test.ts +64 -0
  90. package/server/browser-cursor.ts +69 -0
  91. package/server/browser-frame-context.ts +22 -0
  92. package/server/browser-frame-epoch.test.ts +95 -0
  93. package/server/browser-frame-rejection.ts +17 -0
  94. package/server/browser-gesture.ts +120 -0
  95. package/server/browser-operation-diagnostics.test.ts +79 -0
  96. package/server/browser-operation-diagnostics.ts +103 -0
  97. package/server/browser-policy.ts +1316 -151
  98. package/server/browser-video-input-authority.test.ts +211 -0
  99. package/server/browser-video-policy.test.ts +564 -0
  100. package/server/browser-video-socket.test.ts +368 -0
  101. package/server/browser.ts +151 -16
  102. package/server/capture-transport-policy.test.ts +105 -0
  103. package/server/capture-transport-policy.ts +154 -0
  104. package/server/capture-transport-runtime.test.ts +213 -0
  105. package/server/daemon-launch-chain.test.ts +70 -0
  106. package/server/daemon-shutdown.test.ts +289 -0
  107. package/server/discrete-input-freshness.test.ts +364 -0
  108. package/server/discrete-input-native-lifetime.test.ts +150 -0
  109. package/server/display-loss-recovery.test.ts +294 -0
  110. package/server/emulation-restoration.test.ts +262 -0
  111. package/server/input-cleanup.ts +23 -0
  112. package/server/input-generation.test.ts +31 -0
  113. package/server/input-generation.ts +36 -0
  114. package/server/jpeg-capture-demand.test.ts +76 -0
  115. package/server/jpeg-capture-demand.ts +98 -0
  116. package/server/jpeg-frame.ts +29 -0
  117. package/server/keyboard-input.test.ts +61 -0
  118. package/server/keyboard-input.ts +104 -0
  119. package/server/live-input-policy.test.ts +958 -0
  120. package/server/live-input-runtime.test.ts +732 -0
  121. package/server/live-input-supervisor.test.ts +147 -0
  122. package/server/mcp-entry.ts +44 -2
  123. package/server/native-video-backpressure.test.ts +228 -0
  124. package/server/native-video-buffer.test.ts +141 -0
  125. package/server/native-video-buffer.ts +114 -0
  126. package/server/native-video-capture.test.ts +599 -0
  127. package/server/native-video-capture.ts +497 -0
  128. package/server/native-video-extension-source.test.ts +201 -0
  129. package/server/native-video-extension-source.ts +364 -0
  130. package/server/native-video-extension.test.ts +41 -0
  131. package/server/native-video-extension.ts +83 -0
  132. package/server/native-video-packet.ts +9 -0
  133. package/server/native-video-settings.test.ts +245 -0
  134. package/server/navigation-budget.ts +8 -0
  135. package/server/navigation-commit.ts +125 -0
  136. package/server/navigation-policy.test.ts +153 -0
  137. package/server/process-identity.ts +58 -0
  138. package/server/reattach-held-input.test.ts +143 -0
  139. package/server/runtime-frame-reuse.test.ts +295 -0
  140. package/server/runtime-jpeg-demand.test.ts +200 -0
  141. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  142. package/server/runtime-jpeg-source-order.test.ts +201 -0
  143. package/server/runtime-launch-environment.test.ts +111 -0
  144. package/server/runtime-navigation.test.ts +327 -0
  145. package/server/runtime-owner-display.test.ts +152 -0
  146. package/server/runtime-owner.ts +340 -31
  147. package/server/runtime-page-state.test.ts +249 -0
  148. package/server/runtime-protocol.ts +31 -1
  149. package/server/runtime-target-retention.test.ts +181 -0
  150. package/server/runtime-video-opt-in.test.ts +82 -0
  151. package/server/runtime-video.test.ts +444 -0
  152. package/server/screencast-source-time.ts +46 -0
  153. package/server/stale-wheel-admission.test.ts +136 -0
  154. package/server/supervisor-client.ts +28 -4
  155. package/server/supervisor-heartbeat.test.ts +80 -0
  156. package/server/supervisor.ts +553 -55
  157. package/server/tab-close-confirmation.test.ts +97 -0
  158. package/server/video-source-recovery.test.ts +20 -0
  159. package/server/video-source-recovery.ts +64 -0
  160. package/server/viewer-capture-lifetime.ts +51 -0
  161. package/server/virtual-display.test.ts +184 -0
  162. package/server/virtual-display.ts +236 -0
  163. package/shared/browser-display-preferences.ts +46 -0
  164. package/shared/browser-video.ts +83 -0
  165. package/shared/browser.ts +286 -65
  166. package/shared/capture-density.ts +16 -0
  167. package/shared/capture-settings.ts +17 -0
  168. package/shared/device-presets.ts +90 -0
  169. package/shared/resolution-menu.test.ts +120 -0
  170. package/shared/resolution-menu.ts +72 -0
  171. package/shared/video-settings.test.ts +38 -0
  172. package/shared/video-settings.ts +34 -0
  173. package/shared/viewport-limits.ts +5 -0
@@ -1,13 +1,24 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import type { RpcInput } from "@getpaseo/plugin";
2
+ import { performance } from "node:perf_hooks";
3
+ import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
3
4
  import {
4
5
  type applyDevicePresetRpc,
5
6
  type BrowserFrame,
7
+ type BrowserGestureEvent,
6
8
  type BrowserInputEvent,
7
9
  type BrowserState,
10
+ type BrowserTab,
11
+ type beginBrowserGestureRpc,
12
+ browserCursorSchema,
13
+ browserGestureEventSchema,
14
+ browserTabSchema,
15
+ canUseCaptureDensity,
16
+ type captureBrowserRpc,
17
+ type closeBrowserRpc,
8
18
  DEFAULT_VIEWPORT,
9
19
  DEVICE_PRESETS,
10
20
  type DevicePresetId,
21
+ type endBrowserGestureRpc,
11
22
  FRAME_MAX_BYTES,
12
23
  MAX_VIEWPORT,
13
24
  MIN_VIEWPORT,
@@ -15,38 +26,71 @@ import {
15
26
  type navigateBrowserRpc,
16
27
  type resizeBrowserRpc,
17
28
  type sendBrowserInputRpc,
29
+ type setCaptureDensityRpc,
30
+ type updateBrowserGestureRpc,
18
31
  type Viewport,
19
32
  } from "../shared/browser";
20
- import type { JsonValue } from "./runtime-protocol";
33
+ import {
34
+ type BrowserFrameAuthority,
35
+ type BrowserVideoReadInput,
36
+ type BrowserVideoReadReply,
37
+ readBrowserVideoRpc,
38
+ VIDEO_MAX_BATCH_BYTES,
39
+ VIDEO_SOURCE_CLOCK_TOLERANCE_MS,
40
+ } from "../shared/browser-video";
41
+ import {
42
+ type CaptureQuality,
43
+ captureDimensions,
44
+ DEFAULT_CAPTURE_QUALITY,
45
+ JPEG_QUALITY,
46
+ } from "../shared/capture-settings";
47
+ import { frameMatchesBrowserContext } from "./browser-frame-context";
48
+ import { createBrowserFrameRejection } from "./browser-frame-rejection";
49
+ import { BrowserGesture } from "./browser-gesture";
50
+ import { runWithInputCleanup } from "./input-cleanup";
51
+ import { sameRuntimeInputAttachment } from "./input-generation";
52
+ import { runtimeVideoPacketSchema } from "./native-video-packet";
53
+ import { NAVIGATION_METADATA_TIMEOUT_MS } from "./navigation-budget";
54
+ import { type JsonValue, RuntimeProtocolError } from "./runtime-protocol";
55
+ import { createViewerCaptureLifetime, type ViewerCaptureLifetime } from "./viewer-capture-lifetime";
21
56
  export type WorkspaceValidator = (workspaceId: string) => Promise<void | boolean>;
57
+ type CaptureReply = Omit<RpcOutput<typeof captureBrowserRpc>, "state"> & { state: BrowserState };
58
+ type DensityInput = RpcInput<typeof setCaptureDensityRpc>;
59
+ type CloseInput = RpcInput<typeof closeBrowserRpc>;
22
60
  type NavigateInput = RpcInput<typeof navigateBrowserRpc>;
23
61
  type ResizeInput = RpcInput<typeof resizeBrowserRpc>;
24
62
  type ApplyDevicePresetInput = RpcInput<typeof applyDevicePresetRpc>;
25
63
  type SendInput = RpcInput<typeof sendBrowserInputRpc>;
26
- type CaptureQuality = "low" | "medium" | "high";
27
- type InputTarget = SendInput["target"];
64
+ type BeginGestureInput = RpcInput<typeof beginBrowserGestureRpc>;
65
+ type UpdateGestureInput = RpcInput<typeof updateBrowserGestureRpc>;
66
+ type UpdateGestureReply = Omit<RpcOutput<typeof updateBrowserGestureRpc>, "state"> & {
67
+ state: BrowserState;
68
+ };
69
+ type EndGestureInput = RpcInput<typeof endBrowserGestureRpc>;
28
70
 
29
71
  const VIEWER_TTL_MS = 45_000;
30
72
  const CONTROL_LEASE_MS = 30_000;
31
73
  const FRAME_CACHE_MS = 100;
32
74
  const FRAME_TOKEN_TTL_MS = 5_000;
33
- const MAX_RECENT_FRAMES = 32;
75
+ const MAX_RECENT_FRAMES = 512;
34
76
  const MAX_VIEWERS_PER_SESSION = 16;
35
- const MAX_SESSIONS = 8;
36
- const SCREENCAST_QUALITY = 65;
37
- const RUNTIME_FRAME_MAX_BYTES = 750_000;
77
+ const MAX_SESSIONS = 64;
38
78
  const SCREENCAST_WAIT_MS = 500;
39
- const JPEG_QUALITY = { low: 40, medium: 65, high: 85 } as const;
40
79
 
41
80
  export interface BrowserRuntimeClient {
42
81
  connect(): Promise<{ epoch: number }>;
43
- ensureWorkspace(workspaceId: string): Promise<{
82
+ /** `replaceLost` is internal authority; without it a lost runtime is reported, never replaced. */
83
+ ensureWorkspace(
84
+ workspaceId: string,
85
+ options?: { replaceLost?: boolean },
86
+ ): Promise<{
44
87
  workspaceId: string;
45
88
  runtimeId: string;
46
89
  createdAt: number;
47
90
  }>;
48
91
  requestWorkspace(workspaceId: string, operation: string, input: JsonValue): Promise<JsonValue>;
49
92
  archiveWorkspace(workspaceId: string): Promise<void>;
93
+ closeWorkspace(workspaceId: string, runtimeId: string): Promise<void>;
50
94
  disconnect(): void;
51
95
  }
52
96
 
@@ -54,6 +98,8 @@ export interface SessionManagerOptions {
54
98
  validateWorkspace: WorkspaceValidator;
55
99
  client: BrowserRuntimeClient;
56
100
  now?: () => number;
101
+ /** Same process-local elapsed clock as native capture, injectable for deterministic tests. */
102
+ monotonicNow?: () => number;
57
103
  issueToken?: () => string;
58
104
  viewerTtlMs?: number;
59
105
  controlLeaseMs?: number;
@@ -71,12 +117,15 @@ interface Controller {
71
117
  expiresAt: number;
72
118
  }
73
119
  interface BrowserSession {
120
+ key: string;
74
121
  workspaceId: string;
122
+ tabId: string | null;
75
123
  sessionId: string;
76
124
  runtimeId: string;
77
125
  runtimeCreatedAt: number;
78
126
  bridgeEpoch: number;
79
127
  viewport: Viewport;
128
+ captureScale: number;
80
129
  navigationGeneration: number;
81
130
  viewportGeneration: number;
82
131
  devicePresetId: DevicePresetId | null;
@@ -85,17 +134,43 @@ interface BrowserSession {
85
134
  viewers: Map<string, Viewer>;
86
135
  controller: Controller | null;
87
136
  mutationTail: Promise<void>;
88
- frameCache: Map<CaptureQuality, { frame: BrowserFrame; cachedAt: number }>;
137
+ frameCache: Map<
138
+ CaptureQuality,
139
+ {
140
+ frame: BrowserFrame;
141
+ cachedAt: number;
142
+ /** Exact runtime receipt, distinct from its normalized public frame fields. */
143
+ rawFrame: Record<string, JsonValue>;
144
+ }
145
+ >;
89
146
  recentFrames: Map<
90
147
  string,
91
- { navigationGeneration: number; viewportGeneration: number; expiresAt: number }
148
+ {
149
+ navigationGeneration: number;
150
+ viewportGeneration: number;
151
+ expiresAt: number;
152
+ expiresAtMonotonicMs?: number;
153
+ }
92
154
  >;
93
155
  lastUrl: string;
94
156
  lastTitle: string;
95
157
  canGoBack: boolean;
96
158
  canGoForward: boolean;
97
159
  error: string | null;
160
+ notice: string | null;
161
+ /** Set when the runtime reported it can never serve again; the next explicit attach replaces it. */
162
+ runtimeLost: boolean;
98
163
  archived: boolean;
164
+ gesture: BrowserGesture | null;
165
+ inputGeneration: string | null;
166
+ frameRevision: number;
167
+ /** Playback and input fences use the native capture's Node elapsed clock. */
168
+ videoNotBefore: number | null;
169
+ /** Older native pixels may play, but cannot renew press authority after input. */
170
+ videoInputNotBefore: number | null;
171
+ videoReads: Set<string>;
172
+ videoLifetime: ViewerCaptureLifetime | null;
173
+ videoReceipts: Map<string, { frame: BrowserFrameAuthority; capturedAtMonotonicMs: number }>;
99
174
  }
100
175
 
101
176
  function defaultToken(): string {
@@ -146,6 +221,7 @@ export class SessionManager {
146
221
  private readonly validateWorkspace: WorkspaceValidator;
147
222
  private readonly client: BrowserRuntimeClient;
148
223
  private readonly now: () => number;
224
+ private readonly monotonicNow: () => number;
149
225
  private readonly issueToken: () => string;
150
226
  private readonly viewerTtlMs: number;
151
227
  private readonly controlLeaseMs: number;
@@ -153,8 +229,12 @@ export class SessionManager {
153
229
  private readonly maxSessions: number;
154
230
  private readonly sessions = new Map<string, BrowserSession>();
155
231
  private readonly viewerSessions = new Map<string, BrowserSession>();
232
+ private readonly closedViewerWorkspaces = new Map<string, string>();
233
+ private readonly closedViewerTabs = new Map<string, number>();
156
234
  private readonly sessionCreations = new Map<string, Promise<BrowserSession>>();
235
+ private readonly sessionClosures = new Map<string, Promise<void>>();
157
236
  private readonly archived = new Set<string>();
237
+ private readonly deliberatelyClosed = new Set<string>();
158
238
  private bridgeEpoch = 0;
159
239
  private lifecycleGeneration = 0;
160
240
  private closed = false;
@@ -163,6 +243,7 @@ export class SessionManager {
163
243
  this.validateWorkspace = options.validateWorkspace;
164
244
  this.client = options.client;
165
245
  this.now = options.now ?? Date.now;
246
+ this.monotonicNow = options.monotonicNow ?? (() => performance.now());
166
247
  this.issueToken = options.issueToken ?? defaultToken;
167
248
  this.viewerTtlMs = options.viewerTtlMs ?? VIEWER_TTL_MS;
168
249
  this.controlLeaseMs = options.controlLeaseMs ?? CONTROL_LEASE_MS;
@@ -179,12 +260,27 @@ export class SessionManager {
179
260
 
180
261
  setBridgeEpoch(epoch: number): void {
181
262
  this.bridgeEpoch = epoch;
182
- for (const session of this.sessions.values()) session.bridgeEpoch = epoch;
263
+ for (const session of this.sessions.values()) {
264
+ if (session.bridgeEpoch !== epoch) {
265
+ void this.cancelGesture(session);
266
+ // A new bridge cannot admit a token issued by the previous attachment.
267
+ this.invalidateFrames(session);
268
+ }
269
+ session.bridgeEpoch = epoch;
270
+ }
183
271
  }
184
272
 
273
+ /**
274
+ * `replaceLost` is internal authority held only by the supervisor's explicit human attach
275
+ * path. It lets this call replace a workspace runtime that reported it was lost (before or
276
+ * while attaching) exactly once; every other caller observes the lost session without
277
+ * restarting it.
278
+ */
185
279
  async attach(
186
280
  workspaceId: string,
187
281
  viewerLabel: string,
282
+ tabId?: string,
283
+ options: { replaceLost?: boolean } = {},
188
284
  ): Promise<{ viewerToken: string; state: BrowserState }> {
189
285
  this.assertOpen();
190
286
  const label = viewerLabel.trim();
@@ -192,17 +288,55 @@ export class SessionManager {
192
288
  const validation = await this.validateWorkspace(workspaceId);
193
289
  if (validation === false) throw new Error("Workspace not found");
194
290
  this.assertOpen();
195
- const session = await this.getOrCreateSession(workspaceId);
291
+ const replaceLost = options.replaceLost === true;
292
+ let session = await this.getOrCreateSession(workspaceId, tabId, replaceLost);
293
+ let replaced = false;
294
+ if (session.runtimeLost && replaceLost) {
295
+ session = await this.replaceLostRuntime(session);
296
+ replaced = true;
297
+ }
298
+ const attached = await this.attachViewer(session, label);
299
+ if (session.runtimeLost && replaceLost && !replaced) {
300
+ // The loss was first observed by this very attach. One fenced replacement, no input replay.
301
+ session = await this.replaceLostRuntime(session);
302
+ return this.attachViewer(session, label);
303
+ }
304
+ return attached;
305
+ }
306
+
307
+ /**
308
+ * Discards every page session of the dead workspace runtime behind fresh session and
309
+ * runtime ids; nothing is replayed. Page ids belong to the old browser, so the
310
+ * replacement attaches to the new browser's launch page.
311
+ */
312
+ private async replaceLostRuntime(lost: BrowserSession): Promise<BrowserSession> {
313
+ for (const candidate of [...this.sessions.values()]) {
314
+ if (candidate.workspaceId !== lost.workspaceId) continue;
315
+ candidate.archived = true;
316
+ candidate.videoLifetime?.cancel();
317
+ await this.cancelGesture(candidate);
318
+ for (const token of candidate.viewers.keys()) this.viewerSessions.delete(token);
319
+ candidate.viewers.clear();
320
+ candidate.controller = null;
321
+ this.sessions.delete(candidate.key);
322
+ }
323
+ return this.getOrCreateSession(lost.workspaceId, undefined, true);
324
+ }
325
+
326
+ private attachViewer(
327
+ session: BrowserSession,
328
+ label: string,
329
+ ): Promise<{ viewerToken: string; state: BrowserState }> {
196
330
  return this.serialize(session, async () => {
331
+ if (session.archived) throw new Error("Browser session is closed");
197
332
  this.pruneExpired(session);
198
333
  if (session.viewers.size >= MAX_VIEWERS_PER_SESSION)
199
334
  throw new Error(`Shared browser viewer limit (${MAX_VIEWERS_PER_SESSION}) reached`);
200
335
  const viewerToken = this.issueUniqueToken();
201
336
  session.viewers.set(viewerToken, { label, expiresAt: this.now() + this.viewerTtlMs });
202
337
  this.viewerSessions.set(viewerToken, session);
203
- await this.request(session, "screencast.start", { quality: SCREENCAST_QUALITY }).catch(
204
- () => undefined,
205
- );
338
+ // Actual image reads lazily own JPEG capture. Attaching a desktop video
339
+ // viewer or an agent doing status-only work does not start a second stream.
206
340
  try {
207
341
  return { viewerToken, state: await this.snapshotState(session, viewerToken) };
208
342
  } catch (error) {
@@ -220,64 +354,426 @@ export class SessionManager {
220
354
  this.pruneExpired(session);
221
355
  const detached = session.viewers.delete(viewerToken);
222
356
  this.viewerSessions.delete(viewerToken);
223
- if (session.controller?.viewerToken === viewerToken) session.controller = null;
224
- if (session.viewers.size === 0)
357
+ if (session.controller?.viewerToken === viewerToken) {
358
+ await this.cancelGesture(session);
359
+ session.controller = null;
360
+ }
361
+ if (session.viewers.size === 0) {
225
362
  await this.request(session, "screencast.stop", null).catch(() => undefined);
363
+ await this.stopUnusedVideo(session);
364
+ }
226
365
  return { detached };
227
366
  });
228
367
  }
229
368
 
369
+ /** Enumerate pages in this viewer's workspace without changing any selected page. */
370
+ async listTabs(workspaceId: string): Promise<{ tabs: BrowserTab[] }> {
371
+ if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
372
+ const targets = await this.client.requestWorkspace(workspaceId, "tabs.list", null);
373
+ if (!Array.isArray(targets)) throw new Error("Browser returned an invalid tab list");
374
+ const tabs = await Promise.all(
375
+ targets.map(async (target) => {
376
+ const value = asRecord(target);
377
+ const tabId = browserTabSchema.shape.id.parse(value.targetId);
378
+ const session = [...this.sessions.values()].find(
379
+ (candidate) => candidate.workspaceId === workspaceId && candidate.tabId === tabId,
380
+ );
381
+ const presence = session
382
+ ? await this.serialize(session, async () => {
383
+ this.pruneExpired(session);
384
+ const controllerToken = session.controller?.viewerToken;
385
+ return {
386
+ viewerCount: session.viewers.size,
387
+ controllerLabel: controllerToken
388
+ ? (session.viewers.get(controllerToken)?.label ?? null)
389
+ : null,
390
+ };
391
+ })
392
+ : { viewerCount: 0, controllerLabel: null };
393
+ return browserTabSchema.parse({
394
+ id: tabId,
395
+ title: boundedText(String(value.title ?? ""), 1_024),
396
+ url: boundedText(String(value.url ?? ""), 8_192),
397
+ ...presence,
398
+ });
399
+ }),
400
+ );
401
+ return { tabs };
402
+ }
403
+
404
+ /** Expose tab metadata to a client only through its active viewer lease. */
405
+ async listTabsForViewer(viewerToken: string): Promise<{ tabs: BrowserTab[] }> {
406
+ const session = this.requireViewer(viewerToken);
407
+ const listed = await this.listTabs(session.workspaceId);
408
+ // The lease may have lapsed while the runtime answered; never return metadata then.
409
+ let current = false;
410
+ try {
411
+ current = this.requireViewer(viewerToken) === session && !session.archived;
412
+ } catch {
413
+ current = false;
414
+ }
415
+ if (!current) throw new Error("Browser viewer attachment changed while listing tabs");
416
+ return listed;
417
+ }
418
+
419
+ /** A new page shares the workspace profile but does not change any viewer's selection. */
420
+ async createTab(viewerToken: string): Promise<{ tabId: string }> {
421
+ const session = this.requireViewer(viewerToken);
422
+ const value = asRecord(
423
+ await this.client.requestWorkspace(session.workspaceId, "tabs.create", null),
424
+ );
425
+ // The page now exists. A viewer lease that ended meanwhile must not read as a
426
+ // pre-dispatch refusal, or a caller recovering from an expired viewer would create twice.
427
+ let current = false;
428
+ try {
429
+ current = this.requireViewer(viewerToken) === session && !session.archived;
430
+ } catch {
431
+ current = false;
432
+ }
433
+ if (!current) throw new Error("Browser viewer attachment changed during tab creation");
434
+ const tabId = browserTabSchema.shape.id.parse(value.targetId);
435
+ return { tabId };
436
+ }
437
+
438
+ /** Close only the page controlled by this viewer, leaving other pages and viewers alive. */
439
+ async closeTab(input: {
440
+ viewerToken: string;
441
+ controlToken: string;
442
+ tabId: string;
443
+ }): Promise<{ closed: true }> {
444
+ const session = this.requireViewer(input.viewerToken);
445
+ return this.serialize(session, async () => {
446
+ this.requireController(session, input.viewerToken, input.controlToken);
447
+ if (session.tabId !== input.tabId) throw new Error("Browser tab selection is stale");
448
+ await this.cancelGesture(session);
449
+ await this.request(session, "tabs.close", { targetId: input.tabId });
450
+ session.archived = true;
451
+ session.videoLifetime?.cancel();
452
+ for (const [token, expiresAt] of this.closedViewerTabs) {
453
+ if (expiresAt <= this.now()) this.closedViewerTabs.delete(token);
454
+ }
455
+ for (const token of session.viewers.keys()) {
456
+ this.viewerSessions.delete(token);
457
+ this.closedViewerTabs.set(token, this.now() + this.viewerTtlMs);
458
+ }
459
+ session.viewers.clear();
460
+ session.controller = null;
461
+ this.sessions.delete(session.key);
462
+ return { closed: true };
463
+ });
464
+ }
465
+
466
+ /** Stop one runtime and invalidate every viewer without archiving its workspace or profile. */
467
+ async closeBrowser(input: CloseInput): Promise<{ closed: true }> {
468
+ const session = this.requireViewer(input.viewerToken);
469
+ const closing = this.serialize(session, async () => {
470
+ this.requireController(session, input.viewerToken, input.controlToken);
471
+ if (session.sessionId !== input.sessionId || session.runtimeId !== input.runtimeId)
472
+ throw new Error("Browser session is stale");
473
+
474
+ this.deliberatelyClosed.add(session.workspaceId);
475
+ const workspaceSessions = [...this.sessions.values()].filter(
476
+ (candidate) => candidate.workspaceId === session.workspaceId,
477
+ );
478
+ for (const candidate of workspaceSessions) {
479
+ candidate.videoLifetime?.cancel();
480
+ await this.cancelGesture(candidate);
481
+ candidate.archived = true;
482
+ for (const token of candidate.viewers.keys()) {
483
+ this.viewerSessions.delete(token);
484
+ this.closedViewerWorkspaces.set(token, session.workspaceId);
485
+ }
486
+ candidate.viewers.clear();
487
+ candidate.controller = null;
488
+ }
489
+ try {
490
+ await this.client.closeWorkspace(session.workspaceId, session.runtimeId);
491
+ } finally {
492
+ for (const candidate of workspaceSessions) this.sessions.delete(candidate.key);
493
+ }
494
+ });
495
+ this.sessionClosures.set(session.workspaceId, closing);
496
+ try {
497
+ await closing;
498
+ return { closed: true };
499
+ } finally {
500
+ if (this.sessionClosures.get(session.workspaceId) === closing)
501
+ this.sessionClosures.delete(session.workspaceId);
502
+ }
503
+ }
504
+
505
+ /** Only an explicit human reopen removes the close fence used by viewer and agent attach. */
506
+ async reopenBrowser(workspaceId: string): Promise<{ opened: true }> {
507
+ this.assertOpen();
508
+ const validation = await this.validateWorkspace(workspaceId);
509
+ if (validation === false) throw new Error("Workspace not found");
510
+ const closing = this.sessionClosures.get(workspaceId);
511
+ if (closing) await closing.catch(() => undefined);
512
+ this.deliberatelyClosed.delete(workspaceId);
513
+ try {
514
+ await this.getOrCreateSession(workspaceId);
515
+ } catch (error) {
516
+ this.deliberatelyClosed.add(workspaceId);
517
+ throw error;
518
+ }
519
+ for (const [token, closedWorkspaceId] of this.closedViewerWorkspaces)
520
+ if (closedWorkspaceId === workspaceId) this.closedViewerWorkspaces.delete(token);
521
+ return { opened: true };
522
+ }
523
+
230
524
  async archiveWorkspace(workspaceId: string): Promise<void> {
231
525
  this.assertOpen();
232
526
  this.archived.add(workspaceId);
233
527
  await this.client.archiveWorkspace(workspaceId);
234
- const pending = this.sessionCreations.get(workspaceId);
235
- if (pending) await pending.catch(() => undefined);
236
- const session = this.sessions.get(workspaceId);
237
- if (!session) return;
238
- session.archived = true;
239
- for (const token of session.viewers.keys()) this.viewerSessions.delete(token);
240
- session.viewers.clear();
241
- session.controller = null;
242
- this.sessions.delete(workspaceId);
528
+ const pending = [...this.sessionCreations.entries()]
529
+ .filter(([key]) => key === workspaceId || key.startsWith(`${workspaceId}/`))
530
+ .map(([, creation]) => creation);
531
+ await Promise.allSettled(pending);
532
+ for (const session of this.sessions.values()) {
533
+ if (session.workspaceId !== workspaceId) continue;
534
+ session.archived = true;
535
+ session.videoLifetime?.cancel();
536
+ await this.cancelGesture(session);
537
+ for (const token of session.viewers.keys()) this.viewerSessions.delete(token);
538
+ session.viewers.clear();
539
+ session.controller = null;
540
+ this.sessions.delete(session.key);
541
+ }
243
542
  }
244
543
 
544
+ /** List retained browser sessions even after their last viewer leaves or expires. */
245
545
  async listOpenWorkspaceIds(): Promise<string[]> {
246
546
  this.assertOpen();
247
- const result: string[] = [];
547
+ const result = new Set<string>();
248
548
  for (const session of this.sessions.values()) {
249
549
  await this.serialize(session, async () => {
250
550
  this.pruneExpired(session);
251
- if (session.viewers.size > 0) result.push(session.workspaceId);
551
+ // Viewer leases control access and capture, not the lifetime of the browser.
552
+ // Keep an idle browser discoverable so another device can reattach to it.
553
+ if (!session.archived) result.add(session.workspaceId);
252
554
  });
253
555
  }
254
- return result;
556
+ return [...result];
255
557
  }
256
558
  async status(viewerToken: string): Promise<{ state: BrowserState }> {
257
559
  const session = this.requireViewer(viewerToken);
258
560
  return this.serialize(session, async () => {
561
+ if (session.archived) throw new Error("Browser session is closed");
259
562
  this.pruneExpired(session);
260
563
  this.heartbeatViewer(session, viewerToken);
261
564
  return { state: await this.snapshotState(session, viewerToken) };
262
565
  });
263
566
  }
264
567
 
568
+ /** A lost runtime keeps its typed code so observers can tell it from a transient failure. */
569
+ private metadataFailure(session: BrowserSession, error: string): Error {
570
+ const message = `Browser capture metadata failed: ${error}`;
571
+ return session.runtimeLost
572
+ ? new RuntimeProtocolError("RUNTIME_LOST", message)
573
+ : new Error(message);
574
+ }
575
+
265
576
  async capture(
266
577
  viewerToken: string,
267
- quality: CaptureQuality = "medium",
578
+ quality: CaptureQuality = DEFAULT_CAPTURE_QUALITY,
268
579
  knownFrameId: string | null = null,
269
- ): Promise<{ state: BrowserState; frame: BrowserFrame | null }> {
580
+ ): Promise<CaptureReply> {
270
581
  const session = this.requireViewer(viewerToken);
271
582
  return this.serialize(session, async () => {
272
583
  this.pruneExpired(session);
273
584
  this.heartbeatViewer(session, viewerToken);
585
+ // Reconcile native document identity before capturing and again before
586
+ // issuing authority. A same-URL reload can invalidate the captured pixels.
587
+ const before = await this.snapshotState(session, viewerToken);
588
+ if (before.error) throw this.metadataFailure(session, before.error);
589
+ const inputGeneration = session.inputGeneration;
590
+ const revision = session.frameRevision;
274
591
  const frame = await this.frameForQuality(session, quality);
275
- this.rememberFrame(session, frame);
592
+ const observedAt = this.now();
593
+ const state = await this.snapshotState(session, viewerToken);
594
+ if (state.error) throw this.metadataFailure(session, state.error);
595
+ if (
596
+ inputGeneration !== session.inputGeneration ||
597
+ revision !== session.frameRevision ||
598
+ !frameMatchesBrowserContext(frame, before) ||
599
+ !frameMatchesBrowserContext(frame, state)
600
+ ) {
601
+ throw new Error("Browser changed during capture; request another frame");
602
+ }
603
+ if (observedAt + FRAME_TOKEN_TTL_MS <= this.now()) {
604
+ throw new Error("Browser capture expired during validation; request another frame");
605
+ }
606
+ this.rememberFrame(session, frame, undefined, observedAt);
607
+ return { state, frame: knownFrameId === frame.frameId ? null : frame };
608
+ });
609
+ }
610
+
611
+ /** Change physical density without navigating, switching input mode, or resizing CSS layout. */
612
+ async setCaptureDensity(input: DensityInput): Promise<{ state: BrowserState }> {
613
+ const session = this.requireViewer(input.viewerToken);
614
+ return this.serialize(session, async () => {
615
+ this.requireMutationAccess(session, input);
616
+ if (!canUseCaptureDensity(session.viewport, input.density))
617
+ throw new RangeError("Capture density exceeds the supported image bounds");
618
+ if (session.captureScale !== input.density) {
619
+ await this.cancelGesture(session);
620
+ const preset = DEVICE_PRESETS.find(({ id }) => id === session.devicePresetId);
621
+ await this.request(session, "capture.density", {
622
+ density: input.density,
623
+ deviceScaleFactor: preset?.deviceScaleFactor ?? 1,
624
+ });
625
+ session.captureScale = input.density;
626
+ session.viewportGeneration += 1;
627
+ this.invalidateFrames(session);
628
+ }
629
+ this.renewController(session, input.viewerToken);
630
+ return { state: await this.snapshotState(session, input.viewerToken) };
631
+ });
632
+ }
633
+
634
+ /**
635
+ * Wait outside mutation serialization. Normal media reads use cached policy
636
+ * state and native generation fences. A newly observed native document needs
637
+ * one bounded reconciliation because video viewers may not request JPEG/status.
638
+ * The transition read never admits packets, even after reconciliation succeeds.
639
+ */
640
+ async readVideo(input: BrowserVideoReadInput): Promise<BrowserVideoReadReply> {
641
+ input = readBrowserVideoRpc.input.parse(input);
642
+ const session = this.requireViewer(input.viewerToken);
643
+ const before = await this.serialize(session, async () => {
644
+ this.pruneExpired(session);
645
+ this.heartbeatViewer(session, input.viewerToken);
646
+ if (session.videoReads.has(input.viewerToken)) {
647
+ throw new Error("A video read is already pending for this viewer");
648
+ }
649
+ const state = this.projectState(session, input.viewerToken);
650
+ session.videoReads.add(input.viewerToken);
651
+ this.armVideoLifetime(session);
276
652
  return {
277
- state: await this.snapshotState(session, viewerToken),
278
- frame: knownFrameId === frame.frameId ? null : frame,
653
+ state,
654
+ inputGeneration: session.inputGeneration,
655
+ frameRevision: session.frameRevision,
279
656
  };
280
657
  });
658
+
659
+ try {
660
+ const raw = asRecord(
661
+ await this.request(session, "video.read", {
662
+ quality: input.quality,
663
+ ...(input.bitrate === undefined ? {} : { bitrate: input.bitrate }),
664
+ ...(input.fps === undefined ? {} : { fps: input.fps }),
665
+ streamId: input.streamId,
666
+ afterSequence: input.afterSequence,
667
+ waitMs: input.waitMs,
668
+ requestKeyFrame: input.requestKeyFrame,
669
+ }),
670
+ );
671
+ return await this.serialize(session, async () => {
672
+ if (this.requireViewer(input.viewerToken) !== session || session.archived) {
673
+ throw new Error("Video viewer attachment changed");
674
+ }
675
+ if (raw.inputGeneration !== session.inputGeneration) {
676
+ // External/script navigation must not depend on visual fallback to
677
+ // reconcile policy state. Revoke old receipts first, and discard this
678
+ // entire read: neither pre-transition nor mixed-document pixels qualify.
679
+ this.invalidateFrames(session);
680
+ const state = await this.snapshotState(
681
+ session,
682
+ input.viewerToken,
683
+ NAVIGATION_METADATA_TIMEOUT_MS,
684
+ );
685
+ if (this.requireViewer(input.viewerToken) !== session || session.archived) {
686
+ throw new Error("Video viewer attachment changed");
687
+ }
688
+ return { state, status: "reset", streamId: null, packets: [] };
689
+ }
690
+ const state = this.projectState(session, input.viewerToken);
691
+ const changed =
692
+ session.frameRevision !== before.frameRevision ||
693
+ state.sessionId !== before.state.sessionId ||
694
+ state.runtimeId !== before.state.runtimeId ||
695
+ state.bridgeEpoch !== before.state.bridgeEpoch ||
696
+ state.navigationGeneration !== before.state.navigationGeneration ||
697
+ state.viewportGeneration !== before.state.viewportGeneration ||
698
+ session.inputGeneration !== before.inputGeneration ||
699
+ raw.inputGeneration !== session.inputGeneration;
700
+ if (changed || state.status !== "ready") {
701
+ return { state, status: "reset", streamId: null, packets: [] };
702
+ }
703
+ const dimensions = captureDimensions(session.viewport, session.captureScale);
704
+ const packets: BrowserVideoReadReply["packets"] = [];
705
+ let bytes = 0;
706
+ if (Array.isArray(raw.packets)) {
707
+ for (const value of raw.packets.slice(0, 32)) {
708
+ const parsed = runtimeVideoPacketSchema.safeParse(value);
709
+ if (!parsed.success) throw new Error("Runtime returned an invalid video packet");
710
+ // Private source time cannot be supplied or changed by a remote viewer.
711
+ const { capturedAtMonotonicMs, ...packet } = parsed.data;
712
+ const age = this.monotonicNow() - capturedAtMonotonicMs;
713
+ if (
714
+ (session.videoNotBefore !== null && capturedAtMonotonicMs < session.videoNotBefore) ||
715
+ age < -VIDEO_SOURCE_CLOCK_TOLERANCE_MS ||
716
+ age > 1000 ||
717
+ packet.width !== dimensions.width ||
718
+ packet.height !== dimensions.height
719
+ ) {
720
+ return { state, status: "reset", streamId: null, packets: [] };
721
+ }
722
+ bytes += Buffer.byteLength(packet.dataBase64, "base64");
723
+ if (bytes > VIDEO_MAX_BATCH_BYTES)
724
+ throw new Error("Runtime returned an oversized video batch");
725
+ if (packet.streamId !== raw.streamId)
726
+ throw new Error("Video stream identity changed within a batch");
727
+ const receipt = JSON.stringify([
728
+ packet.streamId,
729
+ packet.captureGeneration,
730
+ packet.sequence,
731
+ packet.timestampUs,
732
+ ]);
733
+ const remembered = session.videoReceipts.get(receipt);
734
+ if (remembered && remembered.capturedAtMonotonicMs !== capturedAtMonotonicMs) {
735
+ throw new Error("Runtime changed an existing video receipt timestamp");
736
+ }
737
+ let frame = remembered?.frame;
738
+ if (!frame) {
739
+ frame = {
740
+ frameId: this.issueUniqueToken(),
741
+ sessionId: session.sessionId,
742
+ runtimeId: session.runtimeId,
743
+ captureEpoch: session.bridgeEpoch,
744
+ navigationGeneration: session.navigationGeneration,
745
+ viewportGeneration: session.viewportGeneration,
746
+ width: packet.width,
747
+ height: packet.height,
748
+ capturedAt: packet.capturedAt,
749
+ };
750
+ session.videoReceipts.set(receipt, { frame, capturedAtMonotonicMs });
751
+ }
752
+ // False when input revoked this capture's time: it may still be painted,
753
+ // but it can never authorize a press, and the viewer must not try.
754
+ const actionable = this.rememberFrame(session, frame, capturedAtMonotonicMs);
755
+ packets.push({ ...packet, frame, actionable });
756
+ }
757
+ }
758
+ while (session.videoReceipts.size > MAX_RECENT_FRAMES) {
759
+ const oldest = session.videoReceipts.keys().next().value;
760
+ if (oldest === undefined) break;
761
+ session.videoReceipts.delete(oldest);
762
+ }
763
+ return readBrowserVideoRpc.output.parse({
764
+ state,
765
+ status: raw.status,
766
+ streamId: raw.streamId,
767
+ packets,
768
+ ...(typeof raw.reason === "string" ? { reason: raw.reason } : {}),
769
+ ...(raw.reasonCode === "encoder-capacity" || raw.reasonCode === "video-disabled"
770
+ ? { reasonCode: raw.reasonCode }
771
+ : {}),
772
+ });
773
+ });
774
+ } finally {
775
+ session.videoReads.delete(input.viewerToken);
776
+ }
281
777
  }
282
778
 
283
779
  async acquireControl(
@@ -290,6 +786,9 @@ export class SessionManager {
290
786
  const current = session.controller;
291
787
  if (current && current.viewerToken !== viewerToken && !takeover)
292
788
  throw new Error("Browser control is held by another viewer");
789
+ if (current?.viewerToken !== viewerToken) {
790
+ await this.cancelGesture(session);
791
+ }
293
792
  const controller =
294
793
  current?.viewerToken === viewerToken
295
794
  ? current
@@ -310,6 +809,7 @@ export class SessionManager {
310
809
  const session = this.requireViewer(viewerToken);
311
810
  return this.serialize(session, async () => {
312
811
  this.requireController(session, viewerToken, controlToken);
812
+ await this.cancelGesture(session);
313
813
  session.controller = null;
314
814
  this.heartbeatViewer(session, viewerToken);
315
815
  return { state: await this.snapshotState(session, viewerToken) };
@@ -320,47 +820,70 @@ export class SessionManager {
320
820
  const session = this.requireViewer(input.viewerToken);
321
821
  return this.serialize(session, async () => {
322
822
  this.requireMutationAccess(session, input);
823
+ await this.cancelGesture(session);
323
824
  const navigationGeneration = session.navigationGeneration;
324
825
  if (input.action.kind === "goto")
325
826
  await this.request(session, "navigate", { url: normalizeBrowserUrl(input.action.url) });
326
827
  else if (input.action.kind === "back") {
327
- await this.refreshPageMetadata(session);
828
+ await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
328
829
  if (!session.canGoBack) throw new Error("Browser cannot go back");
329
830
  await this.request(session, "back", null);
330
831
  } else if (input.action.kind === "forward") {
331
- await this.refreshPageMetadata(session);
832
+ await this.refreshPageMetadata(session, NAVIGATION_METADATA_TIMEOUT_MS);
332
833
  if (!session.canGoForward) throw new Error("Browser cannot go forward");
333
834
  await this.request(session, "forward", null);
334
835
  } else await this.request(session, "reload", null);
335
- await this.refreshPageMetadata(session);
836
+ // One authoritative post-navigation read. Loading is browser-native; an
837
+ // unavailable observation remains an explicit error state, not cached-ready.
838
+ await this.snapshotState(session, input.viewerToken, NAVIGATION_METADATA_TIMEOUT_MS);
336
839
  if (session.navigationGeneration === navigationGeneration) session.navigationGeneration += 1;
337
840
  this.invalidateFrames(session);
338
841
  this.renewController(session, input.viewerToken);
339
- return { state: await this.snapshotState(session, input.viewerToken) };
842
+ return { state: this.projectState(session, input.viewerToken) };
340
843
  });
341
844
  }
342
845
 
846
+ /** Resize CSS layout under the control lease; panel following preserves device behavior and density. */
343
847
  async resize(input: ResizeInput): Promise<{ state: BrowserState }> {
344
848
  const session = this.requireViewer(input.viewerToken);
345
849
  return this.serialize(session, async () => {
346
850
  this.requireMutationAccess(session, input);
851
+ await this.cancelGesture(session);
347
852
  assertViewport(input.viewport);
853
+ const preserveEmulation = input.preserveEmulation === true;
854
+ const captureScale = input.captureDensity ?? (preserveEmulation ? session.captureScale : 1);
855
+ if (
856
+ input.viewport.width * captureScale > MAX_VIEWPORT.width ||
857
+ input.viewport.height * captureScale > MAX_VIEWPORT.height
858
+ ) {
859
+ throw new RangeError(
860
+ "Viewport at the current capture density exceeds supported image bounds",
861
+ );
862
+ }
348
863
  if (
349
864
  session.viewport.width !== input.viewport.width ||
350
865
  session.viewport.height !== input.viewport.height ||
351
- session.devicePresetId !== null
866
+ session.captureScale !== captureScale ||
867
+ (!preserveEmulation && session.devicePresetId !== null)
352
868
  ) {
869
+ const preset = preserveEmulation
870
+ ? DEVICE_PRESETS.find(({ id }) => id === session.devicePresetId)
871
+ : undefined;
353
872
  await this.request(session, "emulate", {
354
873
  ...input.viewport,
355
- deviceScaleFactor: 1,
356
- mobile: false,
357
- touch: false,
358
- userAgent: session.defaultUserAgent,
359
- platform: "",
874
+ deviceScaleFactor: Math.max(preset?.deviceScaleFactor ?? 1, captureScale),
875
+ captureScale,
876
+ mobile: preset?.isMobile ?? false,
877
+ touch: preset?.hasTouch ?? false,
878
+ userAgent: preserveEmulation ? session.userAgent : session.defaultUserAgent,
879
+ platform: preset?.platform ?? "",
360
880
  });
361
881
  session.viewport = { ...input.viewport };
362
- session.devicePresetId = null;
363
- session.userAgent = session.defaultUserAgent;
882
+ session.captureScale = captureScale;
883
+ if (!preserveEmulation) {
884
+ session.devicePresetId = null;
885
+ session.userAgent = session.defaultUserAgent;
886
+ }
364
887
  session.viewportGeneration += 1;
365
888
  this.invalidateFrames(session);
366
889
  }
@@ -373,24 +896,31 @@ export class SessionManager {
373
896
  const session = this.requireViewer(input.viewerToken);
374
897
  return this.serialize(session, async () => {
375
898
  this.requireMutationAccess(session, input);
899
+ await this.cancelGesture(session);
376
900
  const preset = DEVICE_PRESETS.find(({ id }) => id === input.presetId);
377
901
  if (!preset) throw new Error("Unknown device preset");
902
+ // Resolve the display under the session lock. A mode toggle must neither
903
+ // replay remembered dimensions nor change the capture's pixel resolution.
904
+ const viewport = input.preserveDisplay ? session.viewport : preset.viewport;
905
+ const captureScale = input.preserveDisplay ? session.captureScale : preset.captureScale;
378
906
  await this.request(session, "emulate", {
379
- width: preset.viewport.width,
380
- height: preset.viewport.height,
381
- deviceScaleFactor: preset.deviceScaleFactor,
907
+ width: viewport.width,
908
+ height: viewport.height,
909
+ deviceScaleFactor: Math.max(preset.deviceScaleFactor, captureScale),
910
+ captureScale,
382
911
  mobile: preset.isMobile,
383
912
  touch: preset.hasTouch,
384
913
  userAgent: preset.userAgent,
385
914
  platform: preset.platform,
386
915
  });
387
- session.viewport = { ...preset.viewport };
916
+ session.viewport = { ...viewport };
388
917
  session.devicePresetId = preset.id;
918
+ session.captureScale = captureScale;
389
919
  session.userAgent = preset.userAgent;
390
920
  session.viewportGeneration += 1;
391
- session.navigationGeneration += 1;
921
+ // Emulation invalidates input geometry even when display dimensions stay
922
+ // fixed. UA-sniffing sites can use Reload; never discard a draft automatically.
392
923
  this.invalidateFrames(session);
393
- await this.request(session, "reload", null);
394
924
  this.renewController(session, input.viewerToken);
395
925
  return { state: await this.snapshotState(session, input.viewerToken) };
396
926
  });
@@ -401,18 +931,372 @@ export class SessionManager {
401
931
  return this.serialize(session, async () => {
402
932
  this.requireMutationAccess(session, input);
403
933
  this.requireRecentFrame(session, input.target);
404
- await this.dispatchInput(session, input.event, input.target);
405
- this.invalidateFrames(session);
934
+ const assertTargetCurrent = this.discreteInputGuard(session, input);
935
+ try {
936
+ await this.cancelGesture(session);
937
+ await this.dispatchDiscreteInput(session, input.event, assertTargetCurrent);
938
+ } finally {
939
+ const hadVideo = session.videoReceipts.size > 0 || session.videoReads.size > 0;
940
+ // Revoke authority even if auxiliary capture fails. Source-age fencing
941
+ // rejects buffered pre-action video and never overwrites the input outcome.
942
+ this.invalidateFrames(session);
943
+ if (hadVideo) await this.request(session, "video.invalidate", null).catch(() => undefined);
944
+ }
406
945
  this.renewController(session, input.viewerToken);
407
946
  return { state: await this.snapshotState(session, input.viewerToken) };
408
947
  });
409
948
  }
410
949
 
950
+ /**
951
+ * Admit one human channel from a decoded frame receipt that is still fresh.
952
+ * A receipt is bounded by its TTL and cleared by acknowledged input; an idle
953
+ * reopen therefore needs new pixels, never a retained earlier admission. Native
954
+ * begin pins the attachment/document. This never admits agent input.
955
+ */
956
+ async beginGesture(input: BeginGestureInput) {
957
+ const session = this.requireViewer(input.viewerToken);
958
+ return this.serialize(session, async () => {
959
+ this.requireMutationAccess(session, input);
960
+ await this.refreshPageMetadata(session);
961
+ session.error = null;
962
+ this.requireMutationAccess(session, input);
963
+ if (!this.isRecentFrame(session, input.target)) {
964
+ // A pre-input capture can decode after scroll revoked its token. Report
965
+ // known non-publication, not an uncertain runtime failure. The client
966
+ // may obtain another decoded frame before admitting its still-unsent input.
967
+ const state = await this.snapshotState(session, input.viewerToken);
968
+ this.requireMutationAccess(session, input);
969
+ if (state.status !== "ready")
970
+ throw new Error(state.error ?? "Browser runtime is unavailable");
971
+ return { state, admission: "stale-frame" as const };
972
+ }
973
+ if (session.inputGeneration === null)
974
+ throw new Error("Browser input identity is unavailable");
975
+ await this.cancelGesture(session);
976
+ const gesture = new BrowserGesture(
977
+ this.issueUniqueToken(),
978
+ input.viewerToken,
979
+ input.controlToken,
980
+ input.pointerKind,
981
+ { ...input.expected },
982
+ { ...session.viewport },
983
+ this.now(),
984
+ session.inputGeneration,
985
+ );
986
+ session.gesture = gesture;
987
+ try {
988
+ await this.request(session, "input.begin", {
989
+ gestureId: gesture.id,
990
+ expectedInputGeneration: gesture.runtimeInputGeneration,
991
+ });
992
+ await this.assertGestureCurrent(session, gesture);
993
+ this.renewController(session, input.viewerToken);
994
+ this.scheduleGestureTimeout(session, gesture);
995
+ const state = this.projectState(session, input.viewerToken);
996
+ await this.assertGestureCurrent(session, gesture);
997
+ return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence };
998
+ } catch (error) {
999
+ await this.cancelGesture(session, gesture);
1000
+ throw error;
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ /**
1006
+ * Continue an admitted human channel independently of decoded-frame catchup.
1007
+ * Exact control/document/geometry/native checks fence every ordered packet, and
1008
+ * every new press (mouse down or first touch contact) needs its own fresh decoded
1009
+ * receipt. Held motion and releases reuse the channel. Uncertain publication
1010
+ * revokes observations and is never replayed.
1011
+ */
1012
+ async updateGesture(input: UpdateGestureInput): Promise<UpdateGestureReply> {
1013
+ const session = this.requireViewer(input.viewerToken);
1014
+ return this.serialize(session, async () => {
1015
+ const gesture = this.requireGesture(session, input);
1016
+ let publishedEvent: BrowserGestureEvent | null = null;
1017
+ let eventAcknowledged = false;
1018
+ try {
1019
+ this.assertGesturePacketContext(gesture, input.expected);
1020
+ const event = browserGestureEventSchema.parse(input.event);
1021
+ gesture.assertSequence(input.sequence, this.now());
1022
+ await this.assertGestureCurrent(session, gesture);
1023
+ gesture.validate(event);
1024
+ const hover = gesture.isHover(event);
1025
+ if (
1026
+ event.kind === "down" ||
1027
+ (event.kind === "touch" && event.type === "start" && gesture.touches.size === 0)
1028
+ ) {
1029
+ if (!input.target) throw new Error("A current frame is required for a new press");
1030
+ this.requireRecentFrame(session, input.target);
1031
+ }
1032
+ // Revoke agent observations before an outcome can become unknown.
1033
+ this.invalidateGestureObservations(session, hover);
1034
+ if (event.kind === "key") {
1035
+ await this.request(session, "input.key", {
1036
+ gestureId: gesture.id,
1037
+ event: {
1038
+ kind: event.kind,
1039
+ type: event.type,
1040
+ key: event.key,
1041
+ code: event.code,
1042
+ modifiers: event.modifiers,
1043
+ repeat: event.repeat,
1044
+ ...(event.text !== undefined ? { text: event.text } : {}),
1045
+ },
1046
+ });
1047
+ } else if (event.kind === "text") {
1048
+ await this.request(session, "input.text", { gestureId: gesture.id, text: event.text });
1049
+ } else if (event.kind === "leave") {
1050
+ await this.request(session, "mouse.leave", { gestureId: gesture.id });
1051
+ gesture.lastPoint = null;
1052
+ } else if (event.kind === "touch") {
1053
+ const points = event.points.map((point) => ({
1054
+ ...gesture.mapPoint(point),
1055
+ id: point.id,
1056
+ }));
1057
+ await this.request(session, "touch", { gestureId: gesture.id, type: event.type, points });
1058
+ } else {
1059
+ const point = gesture.mapPoint(event.point);
1060
+ const operation =
1061
+ event.kind === "move"
1062
+ ? "mouse.move"
1063
+ : event.kind === "down"
1064
+ ? "mouse.down"
1065
+ : event.kind === "up"
1066
+ ? "mouse.up"
1067
+ : "mouse.wheel";
1068
+ await this.request(session, operation, {
1069
+ ...point,
1070
+ gestureId: gesture.id,
1071
+ ...(event.modifiers === undefined ? {} : { modifiers: event.modifiers }),
1072
+ ...(event.kind === "down" || event.kind === "up"
1073
+ ? { button: event.button, clickCount: event.clickCount }
1074
+ : {}),
1075
+ ...(event.kind === "scroll" ? { deltaX: event.deltaX, deltaY: event.deltaY } : {}),
1076
+ });
1077
+ }
1078
+ publishedEvent = event;
1079
+ await this.assertGestureCurrent(session, gesture);
1080
+ gesture.acknowledge(event, this.now());
1081
+ eventAcknowledged = true;
1082
+ // Fence captures received during publication as well as earlier ones.
1083
+ this.invalidateGestureObservations(session, hover);
1084
+ this.renewController(session, input.viewerToken);
1085
+ this.scheduleGestureTimeout(session, gesture);
1086
+ const cursor = await this.gestureCursor(session, gesture);
1087
+ // An ordinary held-input acknowledgement must not block video behind a
1088
+ // page metadata RPC. Native attachment/document checks remain mandatory;
1089
+ // their navigation failure uses the qualified completion path below.
1090
+ const state = this.projectState(session, input.viewerToken);
1091
+ await this.assertGestureCurrent(session, gesture);
1092
+ return { state, gestureId: gesture.id, nextSequence: gesture.nextSequence, cursor };
1093
+ } catch (error) {
1094
+ if (publishedEvent) {
1095
+ const completion = await this.completeNavigatingInput(
1096
+ session,
1097
+ gesture,
1098
+ publishedEvent,
1099
+ eventAcknowledged,
1100
+ ).catch(() => null);
1101
+ if (completion) return completion;
1102
+ }
1103
+ await this.cancelGesture(session, gesture);
1104
+ throw error;
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ /** Revoke observation authority without resetting the continuous video codec. */
1110
+ private invalidateGestureObservations(session: BrowserSession, hover: boolean): void {
1111
+ session.frameCache.clear();
1112
+ if (!hover) {
1113
+ session.recentFrames.clear();
1114
+ session.videoInputNotBefore = this.monotonicNow();
1115
+ }
1116
+ }
1117
+
1118
+ /** Return a fresh viewing state after acknowledged input navigated, without admitting another input. */
1119
+ private async completeNavigatingInput(
1120
+ session: BrowserSession,
1121
+ gesture: BrowserGesture,
1122
+ event: BrowserGestureEvent,
1123
+ eventAcknowledged: boolean,
1124
+ ) {
1125
+ const state = await this.snapshotState(session, gesture.viewerToken);
1126
+ if (state.navigationGeneration === gesture.expected.navigationGeneration) return null;
1127
+ // Runtime inputGeneration is the private native attachment:document tuple.
1128
+ // A target replacement can also bump policy navigationGeneration, but must
1129
+ // never be accepted as same-attachment navigation after an acknowledged input.
1130
+ if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
1131
+ return null;
1132
+
1133
+ // Only the old document constraint may change after successful publication.
1134
+ // Takeover, viewport changes and runtime/bridge fences are still refused.
1135
+ this.requireMutationAccess(session, {
1136
+ viewerToken: gesture.viewerToken,
1137
+ controlToken: gesture.controlToken,
1138
+ expected: { ...gesture.expected, navigationGeneration: state.navigationGeneration },
1139
+ });
1140
+ if (!eventAcknowledged) gesture.acknowledge(event, this.now());
1141
+ await this.cancelGesture(session, gesture);
1142
+ const finalState = await this.snapshotState(session, gesture.viewerToken);
1143
+ if (!sameRuntimeInputAttachment(gesture.runtimeInputGeneration, session.inputGeneration))
1144
+ return null;
1145
+ this.requireMutationAccess(session, {
1146
+ viewerToken: gesture.viewerToken,
1147
+ controlToken: gesture.controlToken,
1148
+ expected: { ...gesture.expected, navigationGeneration: finalState.navigationGeneration },
1149
+ });
1150
+ this.invalidateFrames(session);
1151
+ this.renewController(session, gesture.viewerToken);
1152
+ return {
1153
+ state: finalState,
1154
+ gestureId: gesture.id,
1155
+ nextSequence: gesture.nextSequence,
1156
+ cursor: null,
1157
+ completion: "navigation" as const,
1158
+ };
1159
+ }
1160
+
1161
+ /** Cancellation releases only this channel, even after a lost update reply or navigation drift. */
1162
+ async endGesture(input: EndGestureInput) {
1163
+ const session = this.requireViewer(input.viewerToken);
1164
+ return this.serialize(session, async () => {
1165
+ const gesture = session.gesture;
1166
+ if (!gesture && input.cancel) {
1167
+ this.requireController(session, input.viewerToken, input.controlToken);
1168
+ return { state: this.projectState(session, input.viewerToken), cursor: null };
1169
+ }
1170
+ const owned = this.requireGesture(session, input);
1171
+ try {
1172
+ if (!input.cancel) {
1173
+ this.assertGesturePacketContext(owned, input.expected);
1174
+ owned.assertSequence(input.sequence, this.now());
1175
+ await this.assertGestureCurrent(session, owned);
1176
+ }
1177
+ } finally {
1178
+ await this.cancelGesture(session, owned);
1179
+ }
1180
+ return { state: this.projectState(session, input.viewerToken), cursor: null };
1181
+ });
1182
+ }
1183
+
1184
+ private requireGesture(
1185
+ session: BrowserSession,
1186
+ input: UpdateGestureInput | EndGestureInput,
1187
+ ): BrowserGesture {
1188
+ const gesture = session.gesture;
1189
+ if (
1190
+ !gesture ||
1191
+ gesture.id !== input.gestureId ||
1192
+ gesture.viewerToken !== input.viewerToken ||
1193
+ gesture.controlToken !== input.controlToken
1194
+ )
1195
+ throw new Error("Browser gesture is unavailable");
1196
+ return gesture;
1197
+ }
1198
+
1199
+ private assertGesturePacketContext(
1200
+ gesture: BrowserGesture,
1201
+ expected: BeginGestureInput["expected"],
1202
+ ): void {
1203
+ // JSON member order is irrelevant; identity fields must match individually.
1204
+ for (const key of [
1205
+ "sessionId",
1206
+ "runtimeId",
1207
+ "bridgeEpoch",
1208
+ "navigationGeneration",
1209
+ "viewportGeneration",
1210
+ ] as const) {
1211
+ if (gesture.expected[key] !== expected[key])
1212
+ throw new Error("Browser gesture context changed");
1213
+ }
1214
+ }
1215
+
1216
+ private async assertGestureCurrent(
1217
+ session: BrowserSession,
1218
+ gesture: BrowserGesture,
1219
+ ): Promise<void> {
1220
+ this.requireMutationAccess(session, {
1221
+ viewerToken: gesture.viewerToken,
1222
+ controlToken: gesture.controlToken,
1223
+ expected: gesture.expected,
1224
+ });
1225
+ if (
1226
+ session.gesture !== gesture ||
1227
+ this.now() >= gesture.idleUntil ||
1228
+ this.now() >= gesture.expiresAt
1229
+ )
1230
+ throw new Error("Browser gesture expired");
1231
+ // Runtime check pins the CDP document/attachment without repeating a full
1232
+ // metadata read for every drag point. Status or a media-generation mismatch
1233
+ // reconciles metadata; acknowledged navigation uses its explicit completion.
1234
+ await this.request(session, "input.check", { gestureId: gesture.id });
1235
+ this.requireMutationAccess(session, {
1236
+ viewerToken: gesture.viewerToken,
1237
+ controlToken: gesture.controlToken,
1238
+ expected: gesture.expected,
1239
+ });
1240
+ if (session.gesture !== gesture) throw new Error("Browser gesture was cancelled");
1241
+ }
1242
+
1243
+ private async gestureCursor(session: BrowserSession, gesture: BrowserGesture) {
1244
+ if (!gesture.lastPoint || gesture.pointerKind !== "mouse") return null;
1245
+ try {
1246
+ const raw = await this.request(session, "cursor", {
1247
+ ...gesture.lastPoint,
1248
+ gestureId: gesture.id,
1249
+ });
1250
+ const parsed = browserCursorSchema.safeParse(raw);
1251
+ return parsed.success ? parsed.data : null;
1252
+ } catch {
1253
+ return null;
1254
+ }
1255
+ }
1256
+
1257
+ private scheduleGestureTimeout(session: BrowserSession, gesture: BrowserGesture): void {
1258
+ if (gesture.timer) clearTimeout(gesture.timer);
1259
+ gesture.timer = setTimeout(
1260
+ () => {
1261
+ void this.serialize(session, async () => {
1262
+ if (
1263
+ session.gesture === gesture &&
1264
+ this.now() >= Math.min(gesture.idleUntil, gesture.expiresAt)
1265
+ ) {
1266
+ await this.cancelGesture(session, gesture);
1267
+ }
1268
+ }).catch(() => undefined);
1269
+ },
1270
+ Math.max(1, Math.min(gesture.idleUntil, gesture.expiresAt) - this.now()),
1271
+ );
1272
+ gesture.timer.unref?.();
1273
+ }
1274
+
1275
+ private async cancelGesture(session: BrowserSession, expected?: BrowserGesture): Promise<void> {
1276
+ const gesture = session.gesture;
1277
+ if (!gesture || (expected && gesture !== expected)) return;
1278
+ session.gesture = null;
1279
+ if (gesture.timer) clearTimeout(gesture.timer);
1280
+ // Cleanup targets the runtime's original attachment, never a newly navigated
1281
+ // or replaced page. Failure is best-effort and cannot replay the input.
1282
+ try {
1283
+ await this.request(session, "input.end", { gestureId: gesture.id });
1284
+ } catch {
1285
+ /* Best-effort old-attachment cleanup must not escape teardown. */
1286
+ }
1287
+ }
1288
+
411
1289
  reset(): void {
412
1290
  this.lifecycleGeneration += 1;
1291
+ for (const session of this.sessions.values()) {
1292
+ session.videoLifetime?.cancel();
1293
+ void this.cancelGesture(session);
1294
+ }
413
1295
  this.sessions.clear();
414
1296
  this.sessionCreations.clear();
415
1297
  this.viewerSessions.clear();
1298
+ this.closedViewerWorkspaces.clear();
1299
+ this.closedViewerTabs.clear();
416
1300
  }
417
1301
 
418
1302
  disconnect(): void {
@@ -422,48 +1306,70 @@ export class SessionManager {
422
1306
  this.reset();
423
1307
  }
424
1308
 
425
- private async getOrCreateSession(workspaceId: string): Promise<BrowserSession> {
1309
+ private async getOrCreateSession(
1310
+ workspaceId: string,
1311
+ tabId?: string,
1312
+ replaceLost = false,
1313
+ ): Promise<BrowserSession> {
426
1314
  if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
427
- const existing = this.sessions.get(workspaceId);
1315
+ const closing = this.sessionClosures.get(workspaceId);
1316
+ if (closing) await closing.catch(() => undefined);
1317
+ if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
1318
+ const existing = [...this.sessions.values()].find(
1319
+ (session) => session.workspaceId === workspaceId && (!tabId || session.tabId === tabId),
1320
+ );
428
1321
  if (existing) return existing;
429
- const pending = this.sessionCreations.get(workspaceId);
430
- if (pending) return pending;
1322
+
1323
+ // The launch page has no known target ID until identity returns. Wait for
1324
+ // another first attachment before deciding whether this target needs a
1325
+ // second policy session, so control and presence cannot split in two.
1326
+ const workspaceCreation = [...this.sessionCreations.entries()].find(
1327
+ ([pendingKey]) => pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`),
1328
+ )?.[1];
1329
+ if (workspaceCreation) {
1330
+ await workspaceCreation.catch(() => undefined);
1331
+ return this.getOrCreateSession(workspaceId, tabId, replaceLost);
1332
+ }
1333
+ const key = tabId ? `${workspaceId}/${tabId}` : workspaceId;
431
1334
  if (this.sessions.size + this.sessionCreations.size >= this.maxSessions)
432
1335
  throw new Error(`Shared browser session limit (${this.maxSessions}) reached`);
433
1336
  const lifecycleGeneration = this.lifecycleGeneration;
434
- const creation = this.createSession(workspaceId);
435
- this.sessionCreations.set(workspaceId, creation);
1337
+ const creation = this.createSession(workspaceId, key, tabId, replaceLost);
1338
+ this.sessionCreations.set(key, creation);
436
1339
  try {
437
1340
  const session = await creation;
438
- if (
439
- this.closed ||
440
- this.archived.has(workspaceId) ||
441
- lifecycleGeneration !== this.lifecycleGeneration
442
- )
443
- throw new Error(
444
- this.closed
445
- ? "Shared browser manager is closed"
446
- : this.archived.has(workspaceId)
447
- ? "Workspace was archived"
448
- : "Shared browser manager was reset",
449
- );
450
- this.sessions.set(workspaceId, session);
1341
+ if (this.closed) throw new Error("Shared browser manager is closed");
1342
+ if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
1343
+ if (this.deliberatelyClosed.has(workspaceId)) throw new Error("Browser is closed");
1344
+ if (lifecycleGeneration !== this.lifecycleGeneration) {
1345
+ throw new Error("Shared browser manager was reset");
1346
+ }
1347
+ this.sessions.set(key, session);
451
1348
  return session;
452
1349
  } catch (error) {
453
1350
  if (this.archived.has(workspaceId)) throw new Error("Workspace was archived");
454
1351
  throw error;
455
1352
  } finally {
456
- if (this.sessionCreations.get(workspaceId) === creation)
457
- this.sessionCreations.delete(workspaceId);
1353
+ if (this.sessionCreations.get(key) === creation) this.sessionCreations.delete(key);
458
1354
  }
459
1355
  }
460
1356
 
461
- private async createSession(workspaceId: string): Promise<BrowserSession> {
462
- let ensured = false;
1357
+ private async createSession(
1358
+ workspaceId: string,
1359
+ key: string,
1360
+ requestedTabId?: string,
1361
+ replaceLost = false,
1362
+ ): Promise<BrowserSession> {
1363
+ let runtimeId: string | null = null;
463
1364
  try {
464
- const descriptor = await this.client.ensureWorkspace(workspaceId);
465
- ensured = true;
466
- const identity = asRecord(await this.client.requestWorkspace(workspaceId, "identity", null));
1365
+ const descriptor = await this.client.ensureWorkspace(workspaceId, { replaceLost });
1366
+ runtimeId = descriptor.runtimeId;
1367
+ const initialTarget = requestedTabId ? { targetId: requestedTabId } : null;
1368
+ const identity = asRecord(
1369
+ await this.client.requestWorkspace(workspaceId, "identity", initialTarget),
1370
+ );
1371
+ const tabId =
1372
+ requestedTabId ?? (typeof identity.targetId === "string" ? identity.targetId : null);
467
1373
  const userAgent = boundedText(String(identity.userAgent ?? "Chromium"), 512);
468
1374
  await this.client.requestWorkspace(workspaceId, "emulate", {
469
1375
  ...DEFAULT_VIEWPORT,
@@ -472,15 +1378,25 @@ export class SessionManager {
472
1378
  touch: false,
473
1379
  userAgent,
474
1380
  platform: "",
1381
+ ...(tabId ? { targetId: tabId } : {}),
475
1382
  });
476
- const state = asRecord(await this.client.requestWorkspace(workspaceId, "state", null));
1383
+ const state = asRecord(
1384
+ await this.client.requestWorkspace(
1385
+ workspaceId,
1386
+ "state",
1387
+ tabId ? { targetId: tabId } : null,
1388
+ ),
1389
+ );
477
1390
  return {
1391
+ key,
478
1392
  workspaceId,
1393
+ tabId,
479
1394
  sessionId: this.issueUniqueToken(),
480
1395
  runtimeId: descriptor.runtimeId,
481
1396
  runtimeCreatedAt: descriptor.createdAt,
482
1397
  bridgeEpoch: this.bridgeEpoch,
483
1398
  viewport: { ...DEFAULT_VIEWPORT },
1399
+ captureScale: 1,
484
1400
  navigationGeneration: 0,
485
1401
  viewportGeneration: 0,
486
1402
  devicePresetId: null,
@@ -491,15 +1407,40 @@ export class SessionManager {
491
1407
  mutationTail: Promise.resolve(),
492
1408
  frameCache: new Map(),
493
1409
  recentFrames: new Map(),
1410
+ frameRevision: 0,
1411
+ videoNotBefore: null,
1412
+ videoInputNotBefore: null,
1413
+ videoReads: new Set(),
1414
+ videoLifetime: null,
1415
+ videoReceipts: new Map(),
494
1416
  lastUrl: boundedText(String(state.url ?? ""), 8192),
495
1417
  lastTitle: boundedText(String(state.title ?? ""), 1024),
496
1418
  canGoBack: Boolean(state.canGoBack),
497
1419
  canGoForward: Boolean(state.canGoForward),
498
1420
  error: null,
1421
+ notice:
1422
+ typeof identity.displayNotice === "string"
1423
+ ? boundedText(identity.displayNotice, 256)
1424
+ : null,
1425
+ runtimeLost: false,
499
1426
  archived: false,
1427
+ gesture: null,
1428
+ inputGeneration: typeof state.inputGeneration === "string" ? state.inputGeneration : null,
500
1429
  };
501
1430
  } catch (error) {
502
- if (ensured) await this.client.archiveWorkspace(workspaceId).catch(() => undefined);
1431
+ const hasOtherSession = [...this.sessions.values()].some(
1432
+ (session) => session.workspaceId === workspaceId && session.key !== key,
1433
+ );
1434
+ const hasOtherCreation = [...this.sessionCreations.keys()].some(
1435
+ (pendingKey) =>
1436
+ pendingKey !== key &&
1437
+ (pendingKey === workspaceId || pendingKey.startsWith(`${workspaceId}/`)),
1438
+ );
1439
+ // An invalid explicit tab must never tear down a workspace runtime that
1440
+ // another participant may already be using without a policy session.
1441
+ if (runtimeId && !requestedTabId && !hasOtherSession && !hasOtherCreation) {
1442
+ await this.client.closeWorkspace(workspaceId, runtimeId).catch(() => undefined);
1443
+ }
503
1444
  throw error;
504
1445
  }
505
1446
  }
@@ -510,12 +1451,62 @@ export class SessionManager {
510
1451
  input: JsonValue,
511
1452
  ): Promise<JsonValue> {
512
1453
  if (session.archived) throw new Error("Workspace was archived");
513
- return this.client.requestWorkspace(session.workspaceId, operation, input);
1454
+ const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
1455
+ return this.client
1456
+ .requestWorkspace(
1457
+ session.workspaceId,
1458
+ operation,
1459
+ session.tabId ? { ...data, targetId: session.tabId } : input,
1460
+ )
1461
+ .catch((error) => {
1462
+ if (error instanceof RuntimeProtocolError && error.code === "RUNTIME_LOST") {
1463
+ // The workspace's single browser is gone, so every page session of it is gone.
1464
+ session.runtimeLost = true;
1465
+ for (const candidate of this.sessions.values())
1466
+ if (candidate.workspaceId === session.workspaceId) candidate.runtimeLost = true;
1467
+ }
1468
+ throw error;
1469
+ });
1470
+ }
1471
+
1472
+ /** Cleanup depends on viewer ownership, never on whether capture produced pixels. */
1473
+ private async stopUnusedVideo(session: BrowserSession): Promise<void> {
1474
+ if (session.viewers.size !== 0) return;
1475
+ session.videoLifetime?.cancel();
1476
+ await this.request(session, "video.stop", null).catch(() => undefined);
1477
+ this.invalidateFrames(session);
1478
+ }
1479
+
1480
+ /** Recheck leases under the same session queue before stopping optional capture. */
1481
+ private armVideoLifetime(session: BrowserSession): void {
1482
+ session.videoLifetime ??= createViewerCaptureLifetime({
1483
+ now: this.now,
1484
+ latestExpiry: () =>
1485
+ session.viewers.size > 0
1486
+ ? Math.max(...[...session.viewers.values()].map((viewer) => viewer.expiresAt))
1487
+ : null,
1488
+ onExpired: () =>
1489
+ this.serialize(session, async () => {
1490
+ if (this.sessions.get(session.key) !== session || session.archived) return;
1491
+ this.pruneExpired(session);
1492
+ await this.stopUnusedVideo(session);
1493
+ }),
1494
+ });
1495
+ session.videoLifetime.arm();
514
1496
  }
515
1497
 
516
1498
  private requireViewer(token: string): BrowserSession {
1499
+ if (this.closedViewerWorkspaces.has(token)) throw new Error("Browser is closed");
1500
+ const closedTabExpiry = this.closedViewerTabs.get(token);
1501
+ if (closedTabExpiry !== undefined) {
1502
+ if (closedTabExpiry > this.now()) throw new Error("Browser tab is closed");
1503
+ this.closedViewerTabs.delete(token);
1504
+ }
517
1505
  const session = this.viewerSessions.get(token);
518
- if (!session || !session.viewers.has(token))
1506
+ const viewer = session?.viewers.get(token);
1507
+ // Expiry is enforced at the authentication boundary itself: an unpruned lapsed lease
1508
+ // never authorizes an entrypoint that does not run pruneExpired first.
1509
+ if (!session || !viewer || viewer.expiresAt <= this.now())
519
1510
  throw new Error("Viewer token is invalid or expired");
520
1511
  return session;
521
1512
  }
@@ -540,8 +1531,10 @@ export class SessionManager {
540
1531
  if (
541
1532
  session.controller &&
542
1533
  (session.controller.expiresAt <= now || !session.viewers.has(session.controller.viewerToken))
543
- )
1534
+ ) {
544
1535
  session.controller = null;
1536
+ void this.serialize(session, () => this.cancelGesture(session)).catch(() => undefined);
1537
+ }
545
1538
  }
546
1539
  private requireController(
547
1540
  session: BrowserSession,
@@ -586,16 +1579,33 @@ export class SessionManager {
586
1579
  session: BrowserSession,
587
1580
  target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
588
1581
  ): void {
1582
+ // Retain diagnostic evidence before validation prunes expired receipts.
1583
+ // An absent receipt cannot distinguish expiry from an earlier revocation.
1584
+ const frame = session.recentFrames.get(target.frameId);
1585
+ if (this.isRecentFrame(session, target)) return;
1586
+ if (!frame) throw createBrowserFrameRejection("admission", "missing-or-revoked");
1587
+
1588
+ const expiresAt = frame.expiresAtMonotonicMs ?? frame.expiresAt;
1589
+ const now = frame.expiresAtMonotonicMs === undefined ? this.now() : this.monotonicNow();
1590
+ const ageMs = now - (expiresAt - FRAME_TOKEN_TTL_MS);
1591
+ const reason = expiresAt <= now ? "expired" : "context-changed";
1592
+ throw createBrowserFrameRejection("admission", reason, ageMs);
1593
+ }
1594
+
1595
+ /** Check issued frame authority without admitting or cancelling a live channel. */
1596
+ private isRecentFrame(
1597
+ session: BrowserSession,
1598
+ target: { frameId: string; navigationGeneration: number; viewportGeneration: number },
1599
+ ): boolean {
589
1600
  this.pruneRecentFrames(session);
590
1601
  const frame = session.recentFrames.get(target.frameId);
591
- if (
592
- !frame ||
593
- target.navigationGeneration !== session.navigationGeneration ||
594
- target.viewportGeneration !== session.viewportGeneration ||
595
- frame.navigationGeneration !== session.navigationGeneration ||
596
- frame.viewportGeneration !== session.viewportGeneration
597
- )
598
- throw new Error("Browser frame is stale");
1602
+ return Boolean(
1603
+ frame &&
1604
+ target.navigationGeneration === session.navigationGeneration &&
1605
+ target.viewportGeneration === session.viewportGeneration &&
1606
+ frame.navigationGeneration === session.navigationGeneration &&
1607
+ frame.viewportGeneration === session.viewportGeneration,
1608
+ );
599
1609
  }
600
1610
  private renewController(session: BrowserSession, token: string): void {
601
1611
  this.heartbeatViewer(session, token);
@@ -627,8 +1637,14 @@ export class SessionManager {
627
1637
  }
628
1638
  private pruneRecentFrames(session: BrowserSession): void {
629
1639
  const now = this.now();
630
- for (const [id, frame] of session.recentFrames)
631
- if (frame.expiresAt <= now) session.recentFrames.delete(id);
1640
+ const monotonicNow = this.monotonicNow();
1641
+ for (const [id, frame] of session.recentFrames) {
1642
+ const expired =
1643
+ frame.expiresAtMonotonicMs === undefined
1644
+ ? frame.expiresAt <= now
1645
+ : frame.expiresAtMonotonicMs <= monotonicNow;
1646
+ if (expired) session.recentFrames.delete(id);
1647
+ }
632
1648
  }
633
1649
  private serialize<T>(session: BrowserSession, operation: () => Promise<T>): Promise<T> {
634
1650
  const result = session.mutationTail.then(operation, operation);
@@ -639,8 +1655,12 @@ export class SessionManager {
639
1655
  return result;
640
1656
  }
641
1657
  private invalidateFrames(session: BrowserSession): void {
1658
+ session.frameRevision += 1;
1659
+ session.videoNotBefore = this.monotonicNow();
1660
+ session.videoInputNotBefore = session.videoNotBefore;
642
1661
  session.frameCache.clear();
643
1662
  session.recentFrames.clear();
1663
+ session.videoReceipts.clear();
644
1664
  }
645
1665
 
646
1666
  private async frameForQuality(
@@ -650,26 +1670,25 @@ export class SessionManager {
650
1670
  const cached = session.frameCache.get(quality);
651
1671
  if (
652
1672
  cached &&
653
- cached.frame.navigationGeneration === session.navigationGeneration &&
654
- cached.frame.viewportGeneration === session.viewportGeneration &&
1673
+ frameMatchesBrowserContext(cached.frame, session) &&
655
1674
  this.now() - cached.cachedAt <= this.frameCacheMs
656
1675
  )
657
1676
  return cached.frame;
658
1677
  const generation = {
1678
+ sessionId: session.sessionId,
1679
+ runtimeId: session.runtimeId,
1680
+ captureEpoch: session.bridgeEpoch,
659
1681
  navigationGeneration: session.navigationGeneration,
660
1682
  viewportGeneration: session.viewportGeneration,
661
1683
  };
662
1684
  const raw = asRecord(
663
1685
  await this.request(session, "frame", {
664
- maxBytes: RUNTIME_FRAME_MAX_BYTES,
1686
+ maxBytes: FRAME_MAX_BYTES,
665
1687
  quality: JPEG_QUALITY[quality],
666
1688
  waitMs: SCREENCAST_WAIT_MS,
667
1689
  }),
668
1690
  );
669
- if (
670
- generation.navigationGeneration !== session.navigationGeneration ||
671
- generation.viewportGeneration !== session.viewportGeneration
672
- )
1691
+ if (!frameMatchesBrowserContext(generation, session))
673
1692
  throw new Error("Browser changed during capture; request another frame");
674
1693
  const byteLength = Number(raw.byteLength);
675
1694
  const dataBase64 = String(raw.dataBase64 ?? "");
@@ -680,8 +1699,33 @@ export class SessionManager {
680
1699
  Buffer.byteLength(dataBase64, "base64") !== byteLength
681
1700
  )
682
1701
  throw new Error("Browser returned an invalid frame");
1702
+ const expectedPixels = captureDimensions(session.viewport, session.captureScale);
1703
+ if (Number(raw.width) !== expectedPixels.width || Number(raw.height) !== expectedPixels.height)
1704
+ throw new Error("Browser returned a frame for a stale viewport");
1705
+
1706
+ // Runtime fallback receipts remain fresh for at most one second. A 100ms
1707
+ // policy-cache expiry must not create a new token and resend the same large
1708
+ // JPEG at every poll. Reuse only its exact receipt, not merely equal pixels.
1709
+ if (
1710
+ cached &&
1711
+ typeof raw.capturedAt === "string" &&
1712
+ Number.isFinite(Date.parse(raw.capturedAt)) &&
1713
+ (raw.transport === "screenshot" || raw.transport === "cdp-screencast") &&
1714
+ session.frameCache.get(quality) === cached &&
1715
+ cached.frame.sessionId === generation.sessionId &&
1716
+ cached.frame.runtimeId === generation.runtimeId &&
1717
+ cached.frame.captureEpoch === generation.captureEpoch &&
1718
+ cached.frame.navigationGeneration === generation.navigationGeneration &&
1719
+ cached.frame.viewportGeneration === generation.viewportGeneration &&
1720
+ (["dataBase64", "byteLength", "width", "height", "capturedAt", "transport"] as const).every(
1721
+ (key) => cached.rawFrame[key] === raw[key],
1722
+ )
1723
+ ) {
1724
+ cached.cachedAt = this.now();
1725
+ return cached.frame;
1726
+ }
1727
+
683
1728
  const frame: BrowserFrame = {
684
- sessionId: session.sessionId,
685
1729
  frameId: this.issueUniqueToken(),
686
1730
  mimeType: "image/jpeg",
687
1731
  transport: raw.transport === "cdp-screencast" ? "cdp-screencast" : "screenshot",
@@ -690,66 +1734,152 @@ export class SessionManager {
690
1734
  width: Number(raw.width),
691
1735
  height: Number(raw.height),
692
1736
  ...generation,
693
- runtimeId: session.runtimeId,
694
- captureEpoch: session.bridgeEpoch,
695
1737
  capturedAt: String(raw.capturedAt),
696
1738
  };
697
- if (frame.width !== session.viewport.width || frame.height !== session.viewport.height)
698
- throw new Error("Browser returned a frame for a stale viewport");
699
- session.frameCache.set(quality, { frame, cachedAt: this.now() });
1739
+ session.frameCache.set(quality, { frame, cachedAt: this.now(), rawFrame: { ...raw } });
700
1740
  return frame;
701
1741
  }
702
1742
 
703
- private rememberFrame(session: BrowserSession, frame: BrowserFrame): void {
1743
+ /** Native source clocks have bounded uncertainty. Displaying a late packet
1744
+ * must never resurrect the press token revoked by acknowledged live input.
1745
+ * JPEG receipts retain the runtime's existing post-input capture-generation fence. */
1746
+ private rememberFrame(
1747
+ session: BrowserSession,
1748
+ frame: BrowserFrameAuthority,
1749
+ capturedAtMonotonicMs?: number,
1750
+ observedAt = this.now(),
1751
+ ): boolean {
1752
+ if (capturedAtMonotonicMs !== undefined && session.videoInputNotBefore !== null) {
1753
+ const exclusiveFloor = session.videoInputNotBefore + VIDEO_SOURCE_CLOCK_TOLERANCE_MS;
1754
+ if (!Number.isFinite(capturedAtMonotonicMs) || capturedAtMonotonicMs <= exclusiveFloor) {
1755
+ session.recentFrames.delete(frame.frameId);
1756
+ return false;
1757
+ }
1758
+ }
704
1759
  this.pruneRecentFrames(session);
705
1760
  session.recentFrames.set(frame.frameId, {
706
1761
  navigationGeneration: frame.navigationGeneration,
707
1762
  viewportGeneration: frame.viewportGeneration,
708
- expiresAt: this.now() + FRAME_TOKEN_TTL_MS,
1763
+ expiresAt: observedAt + FRAME_TOKEN_TTL_MS,
1764
+ ...(capturedAtMonotonicMs === undefined
1765
+ ? {}
1766
+ : {
1767
+ expiresAtMonotonicMs: this.monotonicNow() + FRAME_TOKEN_TTL_MS,
1768
+ }),
709
1769
  });
710
1770
  while (session.recentFrames.size > MAX_RECENT_FRAMES) {
711
1771
  const oldest = session.recentFrames.keys().next().value;
712
1772
  if (!oldest) break;
713
1773
  session.recentFrames.delete(oldest);
714
1774
  }
1775
+ return session.recentFrames.has(frame.frameId);
1776
+ }
1777
+
1778
+ /**
1779
+ * Retain one freshly admitted observation for this operation only. Runtime
1780
+ * latency cannot expire it after publication; control, native document and
1781
+ * every attachment/display fence still revoke it. No continuation is exposed.
1782
+ */
1783
+ private discreteInputGuard(session: BrowserSession, input: SendInput): () => Promise<void> {
1784
+ const context = {
1785
+ viewerToken: input.viewerToken,
1786
+ controlToken: input.controlToken,
1787
+ expected: {
1788
+ sessionId: session.sessionId,
1789
+ runtimeId: session.runtimeId,
1790
+ bridgeEpoch: session.bridgeEpoch,
1791
+ navigationGeneration: session.navigationGeneration,
1792
+ viewportGeneration: session.viewportGeneration,
1793
+ },
1794
+ };
1795
+ const inputGeneration = session.inputGeneration;
1796
+ const revision = session.frameRevision;
1797
+ return async () => {
1798
+ await this.refreshPageMetadata(session);
1799
+ if (
1800
+ inputGeneration !== session.inputGeneration ||
1801
+ revision !== session.frameRevision ||
1802
+ context.expected.navigationGeneration !== session.navigationGeneration ||
1803
+ context.expected.viewportGeneration !== session.viewportGeneration
1804
+ ) {
1805
+ throw createBrowserFrameRejection("admitted-input", "context-changed");
1806
+ }
1807
+ this.requireMutationAccess(session, context);
1808
+ };
1809
+ }
1810
+
1811
+ /** Every discrete native primitive (text, key, press, hover, wheel) runs on one private
1812
+ * channel pinned to the original attachment and document, which the runtime re-checks
1813
+ * before each publication. The ID is never returned to callers and grants no agent
1814
+ * continuation. Native end uses the original page even after navigation, revocation
1815
+ * or a lost down ACK. */
1816
+ private async dispatchDiscreteInput(
1817
+ session: BrowserSession,
1818
+ event: BrowserInputEvent,
1819
+ assertTargetCurrent: () => Promise<void>,
1820
+ ): Promise<void> {
1821
+ // Reconcile the captured document before anything is published, hover included, so a
1822
+ // stalled or failed metadata read holds back every native primitive.
1823
+ await assertTargetCurrent();
1824
+ if (session.inputGeneration === null) {
1825
+ throw new Error("Browser input identity is unavailable");
1826
+ }
1827
+ const gestureId = this.issueUniqueToken();
1828
+ await runWithInputCleanup(
1829
+ async () => {
1830
+ await this.request(session, "input.begin", {
1831
+ gestureId,
1832
+ expectedInputGeneration: session.inputGeneration,
1833
+ });
1834
+ await this.dispatchInput(session, event, assertTargetCurrent, gestureId);
1835
+ },
1836
+ () => this.request(session, "input.end", { gestureId }),
1837
+ );
715
1838
  }
716
1839
 
717
1840
  private async dispatchInput(
718
1841
  session: BrowserSession,
719
1842
  event: BrowserInputEvent,
720
- target: InputTarget,
1843
+ assertTargetCurrent: () => Promise<void>,
1844
+ gestureId: string,
721
1845
  ): Promise<void> {
722
- const assertTargetCurrent = async () => {
723
- await this.refreshPageMetadata(session);
724
- this.requireRecentFrame(session, target);
725
- };
726
- if (event.kind === "type") {
727
- await this.request(session, "text.insert", { text: event.text });
1846
+ // The document was reconciled and pinned by dispatchDiscreteInput; the runtime refuses
1847
+ // every primitive below once its attachment or document differs from that pin.
1848
+ if (event.kind === "move") {
1849
+ const point = mapDisplayedPoint(event.point, session.viewport);
1850
+ await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
728
1851
  return;
729
1852
  }
730
- if (event.kind === "key") {
731
- await this.request(session, "key.down", { key: event.key });
732
- try {
733
- await assertTargetCurrent();
734
- } finally {
735
- await this.request(session, "key.up", { key: event.key });
736
- }
1853
+ if (event.kind === "type") {
1854
+ await this.request(session, "text.insert", {
1855
+ text: event.text,
1856
+ gestureId,
1857
+ });
737
1858
  return;
738
1859
  }
739
- if (event.kind === "move") {
740
- const point = mapDisplayedPoint(event.point, session.viewport);
741
- await this.request(session, "mouse.move", { x: point.x, y: point.y });
1860
+ if (event.kind === "key") {
1861
+ await this.request(session, "key.down", {
1862
+ key: event.key,
1863
+ gestureId,
1864
+ });
1865
+ await runWithInputCleanup(assertTargetCurrent, () =>
1866
+ this.request(session, "key.up", {
1867
+ key: event.key,
1868
+ gestureId,
1869
+ }),
1870
+ );
742
1871
  return;
743
1872
  }
744
1873
  if (event.kind === "scroll") {
745
1874
  const point = mapDisplayedPoint(event.point, session.viewport);
746
- await this.request(session, "mouse.move", { x: point.x, y: point.y });
1875
+ await this.request(session, "mouse.move", { x: point.x, y: point.y, gestureId });
747
1876
  await assertTargetCurrent();
748
1877
  await this.request(session, "mouse.wheel", {
749
1878
  x: point.x,
750
1879
  y: point.y,
751
1880
  deltaX: event.deltaX,
752
1881
  deltaY: event.deltaY,
1882
+ gestureId,
753
1883
  });
754
1884
  return;
755
1885
  }
@@ -757,7 +1887,11 @@ export class SessionManager {
757
1887
  event.kind === "drag" ? event.start : event.point,
758
1888
  session.viewport,
759
1889
  );
760
- await this.request(session, "mouse.move", { x: start.x, y: start.y });
1890
+ await this.request(session, "mouse.move", {
1891
+ x: start.x,
1892
+ y: start.y,
1893
+ gestureId,
1894
+ });
761
1895
  await assertTargetCurrent();
762
1896
  const count = event.kind === "click" ? event.clickCount : 1;
763
1897
  for (let clickCount = 1; clickCount <= count; clickCount += 1) {
@@ -766,54 +1900,83 @@ export class SessionManager {
766
1900
  y: start.y,
767
1901
  button: event.button,
768
1902
  clickCount,
1903
+ gestureId,
769
1904
  });
770
- try {
771
- await assertTargetCurrent();
772
- if (event.kind === "drag") {
773
- const end = mapDisplayedPoint(event.end, session.viewport);
774
- await this.request(session, "mouse.move", { x: end.x, y: end.y });
1905
+ await runWithInputCleanup(
1906
+ async () => {
775
1907
  await assertTargetCurrent();
776
- }
777
- } finally {
778
- const end = event.kind === "drag" ? mapDisplayedPoint(event.end, session.viewport) : start;
779
- await this.request(session, "mouse.up", {
780
- x: end.x,
781
- y: end.y,
782
- button: event.button,
783
- clickCount,
784
- });
785
- }
1908
+ if (event.kind === "drag") {
1909
+ const end = mapDisplayedPoint(event.end, session.viewport);
1910
+ await this.request(session, "mouse.move", {
1911
+ x: end.x,
1912
+ y: end.y,
1913
+ gestureId,
1914
+ });
1915
+ await assertTargetCurrent();
1916
+ }
1917
+ },
1918
+ async () => {
1919
+ const end =
1920
+ event.kind === "drag" ? mapDisplayedPoint(event.end, session.viewport) : start;
1921
+ await this.request(session, "mouse.up", {
1922
+ x: end.x,
1923
+ y: end.y,
1924
+ button: event.button,
1925
+ clickCount,
1926
+ gestureId,
1927
+ });
1928
+ },
1929
+ );
786
1930
  }
787
1931
  }
788
1932
 
789
- private async refreshPageMetadata(session: BrowserSession): Promise<void> {
790
- const raw = asRecord(await this.request(session, "state", null));
1933
+ private async refreshPageMetadata(session: BrowserSession, timeoutMs?: number): Promise<void> {
1934
+ const raw = asRecord(
1935
+ await this.request(session, "state", timeoutMs === undefined ? null : { timeoutMs }),
1936
+ );
791
1937
  const url = boundedText(String(raw.url ?? ""), 8192);
792
- if (session.lastUrl && session.lastUrl !== url) {
1938
+ const inputGeneration = typeof raw.inputGeneration === "string" ? raw.inputGeneration : null;
1939
+ const documentChanged =
1940
+ session.inputGeneration !== null && inputGeneration !== session.inputGeneration;
1941
+ if ((session.lastUrl && session.lastUrl !== url) || documentChanged) {
1942
+ await this.cancelGesture(session);
793
1943
  session.navigationGeneration += 1;
794
1944
  this.invalidateFrames(session);
795
1945
  }
1946
+ session.inputGeneration = inputGeneration;
796
1947
  session.lastUrl = url;
797
1948
  session.lastTitle = boundedText(String(raw.title ?? ""), 1024);
798
1949
  session.canGoBack = Boolean(raw.canGoBack);
799
1950
  session.canGoForward = Boolean(raw.canGoForward);
800
1951
  }
801
1952
 
802
- private async snapshotState(session: BrowserSession, viewerToken: string): Promise<BrowserState> {
1953
+ private async snapshotState(
1954
+ session: BrowserSession,
1955
+ viewerToken: string,
1956
+ timeoutMs?: number,
1957
+ ): Promise<BrowserState> {
803
1958
  this.pruneExpired(session);
804
1959
  if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
805
1960
  try {
806
- await this.refreshPageMetadata(session);
1961
+ await this.refreshPageMetadata(session, timeoutMs);
807
1962
  session.error = null;
808
1963
  } catch (error) {
809
1964
  session.error = boundedText(error instanceof Error ? error.message : String(error), 2048);
810
1965
  }
1966
+ return this.projectState(session, viewerToken);
1967
+ }
1968
+
1969
+ /** Project the last reconciled state without a page RPC. Media generations independently fence late pixels. */
1970
+ private projectState(session: BrowserSession, viewerToken: string): BrowserState {
1971
+ this.pruneExpired(session);
1972
+ if (!session.viewers.has(viewerToken)) throw new Error("Viewer token is invalid or expired");
811
1973
  const controller = session.controller;
812
1974
  const controllerViewer = controller ? session.viewers.get(controller.viewerToken) : null;
813
1975
  return {
814
1976
  sessionId: session.sessionId,
815
1977
  workspaceId: session.workspaceId,
816
1978
  runtimeId: session.runtimeId,
1979
+ ...(session.tabId ? { tabId: session.tabId } : {}),
817
1980
  runtimeCreatedAt: session.runtimeCreatedAt,
818
1981
  bridgeEpoch: session.bridgeEpoch,
819
1982
  status: session.error ? "error" : "ready",
@@ -822,6 +1985,7 @@ export class SessionManager {
822
1985
  canGoBack: session.canGoBack,
823
1986
  canGoForward: session.canGoForward,
824
1987
  viewport: { ...session.viewport },
1988
+ captureScale: session.captureScale,
825
1989
  navigationGeneration: session.navigationGeneration,
826
1990
  viewportGeneration: session.viewportGeneration,
827
1991
  devicePresetId: session.devicePresetId,
@@ -831,6 +1995,7 @@ export class SessionManager {
831
1995
  controllerExpiresAt: controller ? new Date(controller.expiresAt).toISOString() : null,
832
1996
  viewerCount: session.viewers.size,
833
1997
  error: session.error,
1998
+ ...(session.notice ? { notice: session.notice } : {}),
834
1999
  };
835
2000
  }
836
2001
  }