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