@omercnet/paseo-shared-browser 1.2.2-next.232.1 → 1.2.2-next.239.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 (169) 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 +7 -7
  82. package/package.json +9 -5
  83. package/paseo-plugin.json +3 -1
  84. package/scripts/prepare-runtime.mjs +1 -1
  85. package/server/agent-browser-runtime.ts +1598 -314
  86. package/server/browser-cursor.test.ts +64 -0
  87. package/server/browser-cursor.ts +69 -0
  88. package/server/browser-frame-context.ts +22 -0
  89. package/server/browser-frame-epoch.test.ts +95 -0
  90. package/server/browser-frame-rejection.ts +17 -0
  91. package/server/browser-gesture.ts +120 -0
  92. package/server/browser-operation-diagnostics.test.ts +79 -0
  93. package/server/browser-operation-diagnostics.ts +103 -0
  94. package/server/browser-policy.ts +1313 -151
  95. package/server/browser-video-input-authority.test.ts +208 -0
  96. package/server/browser-video-policy.test.ts +564 -0
  97. package/server/browser-video-socket.test.ts +368 -0
  98. package/server/browser.ts +151 -16
  99. package/server/capture-transport-policy.test.ts +105 -0
  100. package/server/capture-transport-policy.ts +154 -0
  101. package/server/capture-transport-runtime.test.ts +213 -0
  102. package/server/daemon-launch-chain.test.ts +70 -0
  103. package/server/daemon-shutdown.test.ts +289 -0
  104. package/server/discrete-input-freshness.test.ts +364 -0
  105. package/server/discrete-input-native-lifetime.test.ts +150 -0
  106. package/server/display-loss-recovery.test.ts +294 -0
  107. package/server/emulation-restoration.test.ts +262 -0
  108. package/server/input-cleanup.ts +23 -0
  109. package/server/input-generation.test.ts +31 -0
  110. package/server/input-generation.ts +36 -0
  111. package/server/jpeg-capture-demand.test.ts +76 -0
  112. package/server/jpeg-capture-demand.ts +98 -0
  113. package/server/jpeg-frame.ts +29 -0
  114. package/server/keyboard-input.test.ts +61 -0
  115. package/server/keyboard-input.ts +104 -0
  116. package/server/live-input-policy.test.ts +958 -0
  117. package/server/live-input-runtime.test.ts +732 -0
  118. package/server/live-input-supervisor.test.ts +147 -0
  119. package/server/mcp-entry.ts +44 -2
  120. package/server/native-video-backpressure.test.ts +228 -0
  121. package/server/native-video-buffer.test.ts +141 -0
  122. package/server/native-video-buffer.ts +114 -0
  123. package/server/native-video-capture.test.ts +599 -0
  124. package/server/native-video-capture.ts +497 -0
  125. package/server/native-video-extension-source.test.ts +201 -0
  126. package/server/native-video-extension-source.ts +364 -0
  127. package/server/native-video-extension.test.ts +41 -0
  128. package/server/native-video-extension.ts +83 -0
  129. package/server/native-video-packet.ts +9 -0
  130. package/server/native-video-settings.test.ts +245 -0
  131. package/server/navigation-budget.ts +8 -0
  132. package/server/navigation-commit.ts +125 -0
  133. package/server/navigation-policy.test.ts +153 -0
  134. package/server/process-identity.ts +58 -0
  135. package/server/reattach-held-input.test.ts +143 -0
  136. package/server/runtime-frame-reuse.test.ts +295 -0
  137. package/server/runtime-jpeg-demand.test.ts +200 -0
  138. package/server/runtime-jpeg-shutdown.test.ts +210 -0
  139. package/server/runtime-jpeg-source-order.test.ts +201 -0
  140. package/server/runtime-launch-environment.test.ts +111 -0
  141. package/server/runtime-navigation.test.ts +327 -0
  142. package/server/runtime-owner-display.test.ts +152 -0
  143. package/server/runtime-owner.ts +305 -31
  144. package/server/runtime-page-state.test.ts +249 -0
  145. package/server/runtime-protocol.ts +31 -1
  146. package/server/runtime-target-retention.test.ts +181 -0
  147. package/server/runtime-video-opt-in.test.ts +82 -0
  148. package/server/runtime-video.test.ts +444 -0
  149. package/server/screencast-source-time.ts +46 -0
  150. package/server/stale-wheel-admission.test.ts +136 -0
  151. package/server/supervisor-client.ts +28 -4
  152. package/server/supervisor-heartbeat.test.ts +80 -0
  153. package/server/supervisor.ts +553 -55
  154. package/server/video-source-recovery.test.ts +20 -0
  155. package/server/video-source-recovery.ts +64 -0
  156. package/server/viewer-capture-lifetime.ts +51 -0
  157. package/server/virtual-display.test.ts +184 -0
  158. package/server/virtual-display.ts +236 -0
  159. package/shared/browser-display-preferences.ts +46 -0
  160. package/shared/browser-video.ts +80 -0
  161. package/shared/browser.ts +286 -65
  162. package/shared/capture-density.ts +16 -0
  163. package/shared/capture-settings.ts +17 -0
  164. package/shared/device-presets.ts +90 -0
  165. package/shared/resolution-menu.test.ts +120 -0
  166. package/shared/resolution-menu.ts +72 -0
  167. package/shared/video-settings.test.ts +38 -0
  168. package/shared/video-settings.ts +34 -0
  169. package/shared/viewport-limits.ts +5 -0
@@ -1,9 +1,26 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
3
  import { chmod, mkdir, open, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { createServer, type Socket } from "node:net";
4
5
  import { homedir, tmpdir } from "node:os";
5
6
  import { dirname, join } from "node:path";
6
- import type { BrowserFrame, BrowserInputEvent, BrowserState, Viewport } from "../shared/browser";
7
+ import type { ZodType } from "zod";
8
+ import type { BrowserFrame, BrowserState } from "../shared/browser";
9
+ import {
10
+ beginBrowserGestureRpc,
11
+ captureBrowserRpc,
12
+ closeBrowserRpc,
13
+ endBrowserGestureRpc,
14
+ MAX_BROWSER_TABS,
15
+ navigateBrowserRpc,
16
+ resizeBrowserRpc,
17
+ sendBrowserInputRpc,
18
+ setCaptureDensityRpc,
19
+ updateBrowserGestureRpc,
20
+ } from "../shared/browser";
21
+ import { readBrowserVideoRpc } from "../shared/browser-video";
22
+ import { DEFAULT_CAPTURE_QUALITY } from "../shared/capture-settings";
23
+ import { MAX_AGENT_VIEWPORT } from "../shared/viewport-limits";
7
24
  import { SessionManager } from "./browser-policy";
8
25
  import { CdpUnknownOutcomeError } from "./cdp";
9
26
  import {
@@ -15,6 +32,7 @@ import {
15
32
  parseRuntimeRequest,
16
33
  RUNTIME_PROTOCOL_VERSION,
17
34
  type RuntimeDescriptor,
35
+ RuntimeLostError,
18
36
  RuntimeProtocolError,
19
37
  type RuntimeRequest,
20
38
  type RuntimeResponse,
@@ -28,6 +46,13 @@ const MAX_MESSAGE_BYTES = 1024 * 1024;
28
46
  const MAX_SUPERVISOR_WORKSPACES = 8;
29
47
  const MAX_SOCKET_IN_FLIGHT = 16;
30
48
  const MAX_GLOBAL_IN_FLIGHT = 64;
49
+ // Optional pixels must leave bounded headroom for ordered input and cleanup.
50
+ // JPEG fallback shares the media budget instead of filling those same slots.
51
+ const MAX_SOCKET_MEDIA_IN_FLIGHT = 12;
52
+ const MAX_GLOBAL_MEDIA_IN_FLIGHT = 48;
53
+ // The one active bridge needs maintenance capacity even when normal reads fill
54
+ // both limits. Duplicate maintenance remains bounded independently of work.
55
+ const MAX_HEARTBEATS_IN_FLIGHT = 1;
31
56
  const AGENT_TICKET_TTL_MS = 10 * 60_000;
32
57
  const MAX_UNBOUND_AGENT_TICKETS = 256;
33
58
  type SupervisorTimer = NodeJS.Timeout;
@@ -55,6 +80,7 @@ export interface RuntimeSupervisorOptions<Runtime extends RuntimeInstance> {
55
80
 
56
81
  interface WorkspaceEntry<Runtime extends RuntimeInstance> {
57
82
  createdAt: number;
83
+ lost?: boolean;
58
84
  runtime: Runtime;
59
85
  }
60
86
 
@@ -70,12 +96,23 @@ interface AgentBinding {
70
96
  ticket: string;
71
97
  agentId: string | null;
72
98
  workspaceId: string | null;
99
+ tabId: string | null;
100
+ selectionRevision: number;
73
101
  viewerToken: string | null;
74
102
  controlToken: string | null;
75
103
  lastState: BrowserState | null;
76
104
  lastFrame: BrowserFrame | null;
77
105
  }
78
106
 
107
+ /** Pair cleanup with a down acknowledged in this exact request/runtime. */
108
+ interface AgentPublicationContext {
109
+ binding: AgentBinding;
110
+ selectionRevision: number;
111
+ keys: Map<string, RuntimeInstance>;
112
+ buttons: Map<string, RuntimeInstance>;
113
+ channels: Map<string, RuntimeInstance>;
114
+ }
115
+
79
116
  export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance> {
80
117
  private readonly owner: RuntimeOwner<Runtime>;
81
118
  private readonly now: () => number;
@@ -90,6 +127,7 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
90
127
  private readonly workspaceOperations = new Map<string, Promise<void>>();
91
128
  private readonly archived = new Set<string>();
92
129
  private readonly browserPolicy: SessionManager;
130
+ private readonly agentRequestContext = new AsyncLocalStorage<AgentPublicationContext>();
93
131
  private readonly agentBindings = new Map<string, AgentBinding>();
94
132
  private readonly agentTickets = new Map<string, Set<string>>();
95
133
  private activeBridge: ActiveBridge | null = null;
@@ -113,14 +151,19 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
113
151
  validateWorkspace: async (workspaceId) => !this.archived.has(workspaceId),
114
152
  client: {
115
153
  connect: async () => ({ epoch: this.nextEpoch }),
116
- ensureWorkspace: (workspaceId) => this.ensureWorkspaceLocal(workspaceId),
154
+ ensureWorkspace: (workspaceId, options) =>
155
+ this.ensureWorkspaceLocal(workspaceId, options?.replaceLost === true),
117
156
  requestWorkspace: (workspaceId, operation, input) =>
118
157
  this.requestWorkspaceLocal(workspaceId, operation, input),
119
158
  archiveWorkspace: (workspaceId) => this.archiveWorkspaceLocal(workspaceId).then(() => {}),
159
+ closeWorkspace: (workspaceId, runtimeId) =>
160
+ this.closeWorkspaceLocal(workspaceId, runtimeId),
120
161
  disconnect: () => {},
121
162
  },
122
163
  now: this.now,
123
- maxSessions: this.maxWorkspaces,
164
+ // Each workspace owns one Chromium runtime but may have several
165
+ // independent page sessions with separate viewers and control leases.
166
+ maxSessions: this.maxWorkspaces * MAX_BROWSER_TABS,
124
167
  });
125
168
  }
126
169
 
@@ -153,6 +196,17 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
153
196
  return this.bridgeLease(bridge);
154
197
  }
155
198
 
199
+ /** Read-only admission for the reserved lane; dispatch still verifies the lease again. */
200
+ isCurrentBridgeLease(bridgeId: string, epoch: number): boolean {
201
+ const bridge = this.activeBridge;
202
+ return Boolean(
203
+ bridge &&
204
+ bridge.bridgeId === bridgeId &&
205
+ bridge.epoch === epoch &&
206
+ bridge.expiresAt > this.now(),
207
+ );
208
+ }
209
+
156
210
  bridgeDisconnected(bridgeId: string, epoch: number): void {
157
211
  if (this.activeBridge?.bridgeId !== bridgeId || this.activeBridge.epoch !== epoch) return;
158
212
  this.activeBridge = null;
@@ -201,10 +255,41 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
201
255
  return result;
202
256
  }
203
257
 
204
- private ensureWorkspaceLocal(workspaceId: string): Promise<RuntimeDescriptor> {
258
+ async closeWorkspace(
259
+ bridgeId: string,
260
+ epoch: number,
261
+ workspaceId: string,
262
+ runtimeId: string,
263
+ ): Promise<void> {
264
+ const bridge = this.assertActiveBridge(bridgeId, epoch);
265
+ await bridge.ready;
266
+ this.assertActiveBridge(bridgeId, epoch);
267
+ await this.closeWorkspaceLocal(workspaceId, runtimeId);
268
+ this.assertActiveBridge(bridgeId, epoch);
269
+ }
270
+
271
+ private ensureWorkspaceLocal(
272
+ workspaceId: string,
273
+ replaceLost = false,
274
+ ): Promise<RuntimeDescriptor> {
205
275
  return this.runWorkspaceOperation(workspaceId, async () => {
206
276
  if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
207
277
  let entry = this.workspaces.get(workspaceId);
278
+ if (entry?.lost) {
279
+ // Only the in-process explicit human attach carries this authority; protocol
280
+ // workspace.ensure and implicit agent paths observe the loss instead.
281
+ if (!replaceLost)
282
+ throw new RuntimeProtocolError(
283
+ "RUNTIME_LOST",
284
+ "Workspace runtime was lost; reconnect explicitly to replace it",
285
+ );
286
+ // Fail closed: release the dead runtime before any replacement exists.
287
+ // Stop first: if the daemon's exit cannot be confirmed the entry stays lost and
288
+ // no replacement is created behind a possibly live session.
289
+ await this.owner.stop(entry.runtime);
290
+ this.workspaces.delete(workspaceId);
291
+ entry = undefined;
292
+ }
208
293
  if (!entry) {
209
294
  let creation = this.creations.get(workspaceId);
210
295
  if (!creation) {
@@ -229,7 +314,10 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
229
314
  operation: string,
230
315
  input: JsonValue,
231
316
  ): Promise<JsonValue> {
232
- return this.runWorkspaceOperation(workspaceId, async () => {
317
+ // Capture the caller before entering the workspace tail. Revocation must
318
+ // fence queued native publication, not only the initial ticket lookup.
319
+ const agent = this.agentRequestContext.getStore();
320
+ const execute = async () => {
233
321
  if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
234
322
  const entry = this.workspaces.get(workspaceId);
235
323
  if (!entry)
@@ -237,12 +325,47 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
237
325
  "WORKSPACE_NOT_FOUND",
238
326
  `Workspace runtime not found: ${workspaceId}`,
239
327
  );
328
+ const data = input && typeof input === "object" && !Array.isArray(input) ? input : {};
329
+ const key = typeof data.key === "string" ? data.key : "";
330
+ const button = typeof data.button === "string" ? data.button : "";
331
+ const channelId = typeof data.gestureId === "string" ? data.gestureId : "";
332
+ const pairedRelease =
333
+ agent &&
334
+ ((operation === "key.up" && agent.keys.get(key) === entry.runtime) ||
335
+ (operation === "mouse.up" && agent.buttons.get(button) === entry.runtime) ||
336
+ (operation === "input.end" && agent.channels.get(channelId) === entry.runtime));
337
+ if (agent && !pairedRelease) {
338
+ this.assertAgentBindingCurrent(agent.binding);
339
+ if (agent.binding.selectionRevision !== agent.selectionRevision) {
340
+ throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent tab selection changed");
341
+ }
342
+ }
343
+ // A lost down/begin ACK still requires cleanup. Record intent only after
344
+ // the current binding fence, immediately before native publication.
345
+ if (agent) {
346
+ if (operation === "key.down") agent.keys.set(key, entry.runtime);
347
+ if (operation === "mouse.down") agent.buttons.set(button, entry.runtime);
348
+ if (operation === "input.begin") agent.channels.set(channelId, entry.runtime);
349
+ }
240
350
  try {
241
351
  const result = await this.owner.request(entry.runtime, operation, input);
352
+ // Revocation blocks new publication, but never strands a possibly published press.
353
+ // These records allow only its policy-generated paired release, on the
354
+ // original runtime, before the outer request refuses its revoked result.
355
+ if (agent) {
356
+ if (operation === "key.up") agent.keys.delete(key);
357
+ if (operation === "mouse.up") agent.buttons.delete(button);
358
+ }
242
359
  if (this.archived.has(workspaceId)) this.workspaceArchived(workspaceId);
360
+ if (this.workspaces.get(workspaceId) !== entry)
361
+ throw new RuntimeProtocolError("RUNTIME_FAILURE", "Video runtime was replaced");
243
362
  return result;
244
363
  } catch (error) {
245
364
  if (error instanceof RuntimeProtocolError) throw error;
365
+ if (error instanceof RuntimeLostError) {
366
+ entry.lost = true;
367
+ throw new RuntimeProtocolError("RUNTIME_LOST", error.message);
368
+ }
246
369
  if (error instanceof CdpUnknownOutcomeError)
247
370
  throw new RuntimeProtocolError(
248
371
  "UNKNOWN_OUTCOME",
@@ -252,8 +375,13 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
252
375
  "RUNTIME_FAILURE",
253
376
  `Workspace runtime ${operation} failed: ${error instanceof Error ? error.message : String(error)}`,
254
377
  );
378
+ } finally {
379
+ if (agent && operation === "input.end") agent.channels.delete(channelId);
255
380
  }
256
- });
381
+ };
382
+ return operation === "video.read"
383
+ ? execute()
384
+ : this.runWorkspaceOperation(workspaceId, execute);
257
385
  }
258
386
 
259
387
  private archiveWorkspaceLocal(workspaceId: string): Promise<{ archived: true }> {
@@ -270,6 +398,19 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
270
398
  });
271
399
  }
272
400
 
401
+ /** Stop the exact runtime confirmed by the viewer while keeping the workspace reusable. */
402
+ private closeWorkspaceLocal(workspaceId: string, runtimeId: string): Promise<void> {
403
+ return this.runWorkspaceOperation(workspaceId, async () => {
404
+ const entry = this.workspaces.get(workspaceId);
405
+ if (!entry || entry.runtime.runtimeId !== runtimeId)
406
+ throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser runtime was replaced");
407
+ this.invalidateAgentObservations(workspaceId);
408
+ this.revokeAgentControl(workspaceId);
409
+ await this.owner.stop(entry.runtime);
410
+ if (this.workspaces.get(workspaceId) === entry) this.workspaces.delete(workspaceId);
411
+ });
412
+ }
413
+
273
414
  private async requestBrowser(
274
415
  bridgeId: string,
275
416
  epoch: number,
@@ -286,6 +427,9 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
286
427
  result = await this.browserPolicy.attach(
287
428
  requireText(data, "workspaceId"),
288
429
  requireText(data, "viewerLabel"),
430
+ typeof data.tabId === "string" ? data.tabId : undefined,
431
+ // Only this bridge-authorised human attach may replace a lost runtime.
432
+ { replaceLost: true },
289
433
  );
290
434
  break;
291
435
  case "detach":
@@ -294,20 +438,28 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
294
438
  case "status":
295
439
  result = await this.browserPolicy.status(requireText(data, "viewerToken"));
296
440
  break;
297
- case "capture":
441
+ case "video.read":
442
+ result = await this.browserPolicy.readVideo(readBrowserVideoRpc.input.parse(data));
443
+ break;
444
+ case "capture": {
445
+ const capture = captureBrowserRpc.input.parse(data);
298
446
  result = await this.browserPolicy.capture(
299
- requireText(data, "viewerToken"),
300
- data.quality === "low" || data.quality === "high" ? data.quality : "medium",
301
- typeof data.knownFrameId === "string" ? data.knownFrameId : null,
447
+ capture.viewerToken,
448
+ capture.quality,
449
+ capture.knownFrameId,
302
450
  );
303
451
  break;
452
+ }
453
+ case "capture.density":
454
+ result = await this.browserPolicy.setCaptureDensity(setCaptureDensityRpc.input.parse(data));
455
+ break;
304
456
  case "acquire-control": {
305
457
  result = await this.browserPolicy.acquireControl(
306
458
  requireText(data, "viewerToken"),
307
459
  data.takeover === true,
308
460
  );
309
461
  const state = stateFromPolicyResult(result);
310
- if (data.takeover === true) this.revokeAgentControl(state.workspaceId);
462
+ if (data.takeover === true) this.revokeAgentControl(state.workspaceId, state.tabId);
311
463
  break;
312
464
  }
313
465
  case "release-control":
@@ -318,23 +470,53 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
318
470
  break;
319
471
  case "navigate":
320
472
  result = await this.browserPolicy.navigate(data as never);
321
- this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
473
+ this.invalidateAgentObservationsForState(result);
322
474
  break;
323
475
  case "viewport":
324
476
  result = await this.browserPolicy.resize(data as never);
325
- this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
477
+ this.invalidateAgentObservationsForState(result);
326
478
  break;
327
479
  case "device":
328
480
  result = await this.browserPolicy.applyDevicePreset(data as never);
329
- this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
481
+ this.invalidateAgentObservationsForState(result);
330
482
  break;
331
483
  case "input":
332
484
  result = await this.browserPolicy.sendInput(data as never);
333
- this.invalidateAgentObservations(stateFromPolicyResult(result).workspaceId);
485
+ this.invalidateAgentObservationsForState(result);
486
+ break;
487
+ case "gesture.begin":
488
+ result = await this.browserPolicy.beginGesture(beginBrowserGestureRpc.input.parse(data));
489
+ break;
490
+ case "gesture.update":
491
+ result = await this.browserPolicy.updateGesture(updateBrowserGestureRpc.input.parse(data));
492
+ this.invalidateAgentObservationsForState(result);
493
+ break;
494
+ case "gesture.end":
495
+ result = await this.browserPolicy.endGesture(endBrowserGestureRpc.input.parse(data));
496
+ this.invalidateAgentObservationsForState(result);
334
497
  break;
335
498
  case "list":
336
499
  result = { workspaceIds: await this.browserPolicy.listOpenWorkspaceIds() };
337
500
  break;
501
+ case "tabs.list":
502
+ result = await this.browserPolicy.listTabsForViewer(requireText(data, "viewerToken"));
503
+ break;
504
+ case "tabs.create":
505
+ result = await this.browserPolicy.createTab(requireText(data, "viewerToken"));
506
+ break;
507
+ case "tabs.close":
508
+ result = await this.browserPolicy.closeTab({
509
+ viewerToken: requireText(data, "viewerToken"),
510
+ controlToken: requireText(data, "controlToken"),
511
+ tabId: requireText(data, "tabId"),
512
+ });
513
+ break;
514
+ case "close":
515
+ result = await this.browserPolicy.closeBrowser(closeBrowserRpc.input.parse(data));
516
+ break;
517
+ case "reopen":
518
+ result = await this.browserPolicy.reopenBrowser(requireText(data, "workspaceId"));
519
+ break;
338
520
  case "archive": {
339
521
  const workspaceId = requireText(data, "workspaceId");
340
522
  await this.revokeWorkspace(workspaceId);
@@ -371,6 +553,8 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
371
553
  issuedAt: this.now(),
372
554
  agentId: null,
373
555
  workspaceId: null,
556
+ tabId: null,
557
+ selectionRevision: 0,
374
558
  viewerToken: null,
375
559
  controlToken: null,
376
560
  lastState: null,
@@ -408,34 +592,125 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
408
592
  this.assertActiveBridge(bridgeId, epoch);
409
593
  const tickets = this.agentTickets.get(agentId);
410
594
  if (!tickets) return { revoked: 0 };
595
+ const viewers: string[] = [];
411
596
  let revoked = 0;
597
+ // Remove every credential synchronously before waiting on viewer cleanup.
412
598
  for (const ticket of tickets) {
413
599
  const binding = this.agentBindings.get(ticket);
414
600
  if (!binding) continue;
415
601
  this.agentBindings.delete(ticket);
416
602
  revoked += 1;
417
- if (binding.viewerToken)
418
- await this.browserPolicy.detach(binding.viewerToken).catch(() => undefined);
603
+ if (binding.viewerToken) viewers.push(binding.viewerToken);
419
604
  }
420
605
  this.agentTickets.delete(agentId);
606
+ await Promise.allSettled(viewers.map((viewerToken) => this.browserPolicy.detach(viewerToken)));
421
607
  return { revoked };
422
608
  }
423
609
 
424
- private async requestAgent(
425
- ticket: string,
610
+ /** Carry exact ticket identity through awaited policy and native publication. */
611
+ private requestAgent(ticket: string, operation: string, input: JsonValue): Promise<JsonValue> {
612
+ this.assertPluginAvailable();
613
+ const binding = this.requireAgentBinding(ticket);
614
+ const execute = () => this.executeAgentRequest(binding, operation, input);
615
+ // Attachment constructs shared session state; its late viewer is removed
616
+ // explicitly. Only actionable requests carry a native publication fence.
617
+ if (
618
+ operation === "status" ||
619
+ operation === "capture" ||
620
+ operation === "acquire-control" ||
621
+ operation === "tabs.list"
622
+ ) {
623
+ return execute();
624
+ }
625
+ return this.agentRequestContext.run(
626
+ {
627
+ binding,
628
+ selectionRevision: binding.selectionRevision,
629
+ keys: new Map(),
630
+ buttons: new Map(),
631
+ channels: new Map(),
632
+ },
633
+ execute,
634
+ );
635
+ }
636
+
637
+ private async executeAgentRequest(
638
+ binding: AgentBinding,
426
639
  operation: string,
427
640
  input: JsonValue,
428
641
  ): Promise<JsonValue> {
429
- this.assertPluginAvailable();
430
- const binding = this.requireAgentBinding(ticket);
642
+ this.assertAgentBindingCurrent(binding);
431
643
  const data = asObject(input);
432
644
  if (operation === "status" || operation === "capture")
433
645
  return (await this.requestAgentObservation(binding, operation, data)) as unknown as JsonValue;
646
+ if (operation === "tabs.list") {
647
+ const listed = await this.browserPolicy.listTabs(binding.workspaceId!);
648
+ return { ...listed, selectedTabId: binding.tabId };
649
+ }
650
+ if (operation === "tabs.select") {
651
+ const tabId = requireText(data, "tabId");
652
+ return { state: await this.selectAgentTab(binding, tabId) } as unknown as JsonValue;
653
+ }
654
+ if (operation === "tabs.create") {
655
+ const create = async () =>
656
+ this.browserPolicy.createTab(await this.ensureAgentViewer(binding));
657
+ let created: { tabId: string };
658
+ try {
659
+ created = await create();
660
+ } catch (error) {
661
+ // Only a refusal before dispatch is recoverable: the lapsed agent viewer is replaced
662
+ // once. A lease that ends after the page was created is a distinct, non-retried error.
663
+ if (!isInvalidViewer(error)) throw error;
664
+ binding.viewerToken = null;
665
+ binding.controlToken = null;
666
+ created = await create();
667
+ }
668
+ return {
669
+ tabId: created.tabId,
670
+ state: await this.selectAgentTab(binding, created.tabId),
671
+ } as unknown as JsonValue;
672
+ }
673
+ if (operation === "tabs.close") {
674
+ const viewerToken = binding.viewerToken;
675
+ const controlToken = binding.controlToken;
676
+ const tabId = binding.tabId;
677
+ if (!viewerToken || !controlToken || !tabId) {
678
+ throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent does not control a tab");
679
+ }
680
+ await this.browserPolicy.closeTab({ viewerToken, controlToken, tabId });
681
+ binding.viewerToken = null;
682
+ binding.tabId = null;
683
+ binding.controlToken = null;
684
+ binding.lastState = null;
685
+ binding.lastFrame = null;
686
+ const { tabs } = await this.browserPolicy.listTabs(binding.workspaceId!);
687
+ const next = tabs[0];
688
+ if (!next) throw new RuntimeProtocolError("RUNTIME_FAILURE", "Browser has no remaining tab");
689
+ return { state: await this.selectAgentTab(binding, next.id) } as unknown as JsonValue;
690
+ }
434
691
  try {
435
692
  if (operation === "acquire-control") {
693
+ const selectionRevision = binding.selectionRevision;
436
694
  await this.requestAgentObservation(binding, "status", {});
695
+ this.assertAgentBindingCurrent(binding);
696
+ if (binding.selectionRevision !== selectionRevision) {
697
+ throw new RuntimeProtocolError(
698
+ "INVALID_REQUEST",
699
+ "Agent tab changed during control acquisition",
700
+ );
701
+ }
437
702
  const viewerToken = binding.viewerToken!;
438
703
  const result = await this.browserPolicy.acquireControl(viewerToken, false);
704
+ this.assertAgentBindingCurrent(binding);
705
+ if (binding.selectionRevision !== selectionRevision) {
706
+ await this.browserPolicy
707
+ .releaseControl(viewerToken, result.controlToken)
708
+ .catch(() => undefined);
709
+ throw new RuntimeProtocolError(
710
+ "INVALID_REQUEST",
711
+ "Agent tab changed during control acquisition",
712
+ );
713
+ }
439
714
  binding.controlToken = result.controlToken;
440
715
  binding.lastState = result.state;
441
716
  return { state: result.state } as unknown as JsonValue;
@@ -456,35 +731,54 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
456
731
  binding.controlToken = null;
457
732
  }
458
733
  }
734
+ this.assertAgentBindingCurrent(binding);
735
+ const selectionRevision = binding.selectionRevision;
459
736
  const expected = expectedState(binding);
460
737
  let result: { state: BrowserState };
461
738
  if (operation === "navigate") {
462
- result = await this.browserPolicy.navigate({
463
- viewerToken,
464
- controlToken,
465
- expected,
466
- action: data.action as never,
467
- });
739
+ result = await this.browserPolicy.navigate(
740
+ parseAgentInput(navigateBrowserRpc.input, {
741
+ viewerToken,
742
+ controlToken,
743
+ expected,
744
+ action: data.action,
745
+ }),
746
+ );
468
747
  } else if (operation === "viewport") {
469
- result = await this.browserPolicy.resize({
748
+ const resize = parseAgentInput(resizeBrowserRpc.input, {
470
749
  viewerToken,
471
750
  controlToken,
472
751
  expected,
473
- viewport: data.viewport as Viewport,
752
+ viewport: data.viewport,
474
753
  });
754
+ // The shared human schema allows MAX_VIEWPORT; an agent ticket keeps the
755
+ // smaller original bound, enforced here before any mutation.
756
+ if (
757
+ resize.viewport.width > MAX_AGENT_VIEWPORT.width ||
758
+ resize.viewport.height > MAX_AGENT_VIEWPORT.height
759
+ ) {
760
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Invalid agent browser request");
761
+ }
762
+ result = await this.browserPolicy.resize(resize);
475
763
  } else if (operation === "input") {
476
764
  if (!binding.lastFrame)
477
765
  throw new RuntimeProtocolError("INVALID_REQUEST", "Capture a frame before sending input");
478
- result = await this.browserPolicy.sendInput({
479
- viewerToken,
480
- controlToken,
481
- expected,
482
- target: binding.lastFrame,
483
- event: data.event as BrowserInputEvent,
484
- });
766
+ result = await this.browserPolicy.sendInput(
767
+ parseAgentInput(sendBrowserInputRpc.input, {
768
+ viewerToken,
769
+ controlToken,
770
+ expected,
771
+ target: binding.lastFrame,
772
+ event: data.event,
773
+ }),
774
+ );
485
775
  } else {
486
776
  throw new RuntimeProtocolError("INVALID_REQUEST", `Unknown agent operation: ${operation}`);
487
777
  }
778
+ this.assertAgentBindingCurrent(binding);
779
+ if (binding.selectionRevision !== selectionRevision) {
780
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during action");
781
+ }
488
782
  binding.lastState = result.state;
489
783
  binding.lastFrame = null;
490
784
  return result as unknown as JsonValue;
@@ -500,12 +794,15 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
500
794
  operation: "status" | "capture",
501
795
  input: Record<string, JsonValue>,
502
796
  ): Promise<{ state: BrowserState; frame?: BrowserFrame | null }> {
797
+ const selectionRevision = binding.selectionRevision;
503
798
  const run = async (viewerToken: string) =>
504
799
  operation === "status"
505
800
  ? await this.browserPolicy.status(viewerToken)
506
801
  : await this.browserPolicy.capture(
507
802
  viewerToken,
508
- input.quality === "low" || input.quality === "high" ? input.quality : "medium",
803
+ input.quality === "low" || input.quality === "medium" || input.quality === "high"
804
+ ? input.quality
805
+ : DEFAULT_CAPTURE_QUALITY,
509
806
  null,
510
807
  );
511
808
  let result: { state: BrowserState; frame?: BrowserFrame | null };
@@ -517,23 +814,102 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
517
814
  binding.controlToken = null;
518
815
  result = await run(await this.ensureAgentViewer(binding));
519
816
  }
817
+ this.assertAgentBindingCurrent(binding);
818
+ if (binding.selectionRevision !== selectionRevision) {
819
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during observation");
820
+ }
520
821
  binding.lastState = result.state;
822
+ binding.tabId = result.state.tabId ?? null;
521
823
  if (operation === "capture") binding.lastFrame = result.frame ?? null;
522
824
  return result;
523
825
  }
524
826
 
827
+ /** Move only this agent's viewer to a page, preserving every other viewer's selection. */
828
+ private async selectAgentTab(binding: AgentBinding, tabId: string): Promise<BrowserState> {
829
+ this.assertAgentBindingCurrent(binding);
830
+ if (binding.tabId === tabId && binding.viewerToken) {
831
+ const current = await this.browserPolicy.status(binding.viewerToken);
832
+ this.assertAgentBindingCurrent(binding);
833
+ if (binding.tabId !== tabId) {
834
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during selection");
835
+ }
836
+ binding.lastState = current.state;
837
+ return current.state;
838
+ }
839
+ const revision = ++binding.selectionRevision;
840
+ const requestContext = this.agentRequestContext.getStore();
841
+ if (requestContext?.binding === binding) requestContext.selectionRevision = revision;
842
+ const previousViewer = binding.viewerToken;
843
+ binding.controlToken = null;
844
+ binding.lastState = null;
845
+ binding.lastFrame = null;
846
+ const attached = await this.browserPolicy.attach(
847
+ binding.workspaceId!,
848
+ `Agent ${binding.agentId!.slice(0, 48)}`,
849
+ tabId,
850
+ );
851
+ try {
852
+ this.assertAgentBindingCurrent(binding);
853
+ if (binding.selectionRevision !== revision) {
854
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab selection was replaced");
855
+ }
856
+ if (previousViewer) await this.browserPolicy.detach(previousViewer);
857
+ this.assertAgentBindingCurrent(binding);
858
+ binding.viewerToken = attached.viewerToken;
859
+ binding.tabId = attached.state.tabId ?? tabId;
860
+ binding.controlToken = null;
861
+ binding.lastState = attached.state;
862
+ binding.lastFrame = null;
863
+ return attached.state;
864
+ } catch (error) {
865
+ await this.browserPolicy.detach(attached.viewerToken).catch(() => undefined);
866
+ throw error;
867
+ }
868
+ }
869
+
870
+ /** Publish an attached viewer only while its original credential still exists. */
525
871
  private async ensureAgentViewer(binding: AgentBinding): Promise<string> {
872
+ this.assertAgentBindingCurrent(binding);
526
873
  if (binding.viewerToken) return binding.viewerToken;
527
- this.assertPluginAvailable();
874
+ const selectionRevision = binding.selectionRevision;
528
875
  const attached = await this.browserPolicy.attach(
529
876
  binding.workspaceId!,
530
877
  `Agent ${binding.agentId!.slice(0, 48)}`,
878
+ binding.tabId ?? undefined,
531
879
  );
880
+ try {
881
+ this.assertAgentBindingCurrent(binding);
882
+ if (binding.selectionRevision !== selectionRevision) {
883
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during attachment");
884
+ }
885
+ } catch (error) {
886
+ // Cleanup must run outside the revoked caller's publication fence.
887
+ await this.agentRequestContext
888
+ .exit(() => this.browserPolicy.detach(attached.viewerToken))
889
+ .catch(() => undefined);
890
+ throw error;
891
+ }
892
+ if (binding.viewerToken) {
893
+ // Parallel observations may both attach. Keep the first published viewer.
894
+ await this.agentRequestContext.exit(() => this.browserPolicy.detach(attached.viewerToken));
895
+ this.assertAgentBindingCurrent(binding);
896
+ return binding.viewerToken;
897
+ }
532
898
  binding.viewerToken = attached.viewerToken;
533
899
  binding.lastState = attached.state;
900
+ binding.tabId = attached.state.tabId ?? null;
534
901
  return attached.viewerToken;
535
902
  }
536
903
 
904
+ /** A deleted or replaced ticket cannot regain authority after an awaited step. */
905
+ private assertAgentBindingCurrent(binding: AgentBinding): void {
906
+ this.assertPluginAvailable();
907
+ if (this.agentBindings.get(binding.ticket) !== binding) {
908
+ throw new RuntimeProtocolError("AUTHENTICATION_FAILED", "Agent ticket was revoked");
909
+ }
910
+ if (this.archived.has(binding.workspaceId!)) this.workspaceArchived(binding.workspaceId!);
911
+ }
912
+
537
913
  private requireAgentBinding(ticket: string): AgentBinding {
538
914
  const binding = this.agentBindings.get(ticket);
539
915
  if (!binding?.agentId || !binding.workspaceId)
@@ -547,18 +923,25 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
547
923
  throw new RuntimeProtocolError("BRIDGE_FENCED", "Shared Browser plugin is unavailable");
548
924
  }
549
925
 
550
- private revokeAgentControl(workspaceId: string): void {
926
+ private revokeAgentControl(workspaceId: string, tabId?: string): void {
551
927
  for (const binding of this.agentBindings.values()) {
552
928
  if (binding.workspaceId !== workspaceId) continue;
929
+ if (tabId && binding.tabId !== tabId) continue;
553
930
  binding.controlToken = null;
554
931
  binding.lastState = null;
555
932
  binding.lastFrame = null;
556
933
  }
557
934
  }
558
935
 
559
- private invalidateAgentObservations(workspaceId: string): void {
936
+ private invalidateAgentObservationsForState(result: unknown): void {
937
+ const state = stateFromPolicyResult(result);
938
+ this.invalidateAgentObservations(state.workspaceId, state.tabId);
939
+ }
940
+
941
+ private invalidateAgentObservations(workspaceId: string, tabId?: string): void {
560
942
  for (const binding of this.agentBindings.values()) {
561
943
  if (binding.workspaceId !== workspaceId) continue;
944
+ if (tabId && binding.tabId !== tabId) continue;
562
945
  binding.lastState = null;
563
946
  binding.lastFrame = null;
564
947
  }
@@ -634,6 +1017,13 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
634
1017
  );
635
1018
  case "workspace.archive":
636
1019
  return this.archiveWorkspace(request.bridgeId, request.epoch, request.workspaceId);
1020
+ case "workspace.close":
1021
+ return this.closeWorkspace(
1022
+ request.bridgeId,
1023
+ request.epoch,
1024
+ request.workspaceId,
1025
+ request.runtimeId,
1026
+ ).then(() => ({ closed: true }));
637
1027
  case "browser.request":
638
1028
  return this.requestBrowser(
639
1029
  request.bridgeId,
@@ -760,6 +1150,16 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
760
1150
  }
761
1151
  }
762
1152
 
1153
+ /** Raw agent sockets reuse the public mutation contract. Never expose parser
1154
+ * issues or submitted values in errors from this trusted-ticket boundary. */
1155
+ function parseAgentInput<Value>(schema: ZodType<Value>, value: unknown): Value {
1156
+ const parsed = schema.safeParse(value);
1157
+ if (!parsed.success) {
1158
+ throw new RuntimeProtocolError("INVALID_REQUEST", "Invalid agent browser request");
1159
+ }
1160
+ return parsed.data;
1161
+ }
1162
+
763
1163
  function asObject(value: JsonValue): Record<string, JsonValue> {
764
1164
  if (!value || typeof value !== "object" || Array.isArray(value))
765
1165
  throw new RuntimeProtocolError("INVALID_REQUEST", "Browser input must be an object");
@@ -872,13 +1272,18 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
872
1272
  const supervisor = new RuntimeSupervisor({ owner });
873
1273
  const sockets = new Set<Socket>();
874
1274
  let globalInFlight = 0;
1275
+ let globalMediaInFlight = 0;
1276
+ let globalHeartbeatsInFlight = 0;
875
1277
  const token = randomBytes(32).toString("base64url");
876
1278
  const server = createServer((socket) => {
877
1279
  sockets.add(socket);
878
1280
  let buffer = "";
879
1281
  let claimed: { bridgeId: string; epoch: number } | null = null;
880
1282
  let socketInFlight = 0;
1283
+ let socketMediaInFlight = 0;
1284
+ let socketHeartbeatsInFlight = 0;
881
1285
  let processing = Promise.resolve();
1286
+ let busyResponsePending = false;
882
1287
  socket.setEncoding("utf8");
883
1288
 
884
1289
  const rejectBusy = (line: string): boolean => {
@@ -891,6 +1296,7 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
891
1296
  socket.destroy();
892
1297
  return false;
893
1298
  }
1299
+ busyResponsePending = true;
894
1300
  socket.pause();
895
1301
  socket.write(
896
1302
  `${JSON.stringify({
@@ -902,19 +1308,46 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
902
1308
  },
903
1309
  })}\n`,
904
1310
  (error) => {
1311
+ busyResponsePending = false;
905
1312
  if (error) socket.destroy();
906
- else socket.resume();
1313
+ else drainBufferedLines();
907
1314
  },
908
1315
  );
909
1316
  return false;
910
1317
  };
911
1318
 
912
1319
  const enqueue = (line: string): boolean => {
913
- if (socketInFlight >= MAX_SOCKET_IN_FLIGHT || globalInFlight >= MAX_GLOBAL_IN_FLIGHT)
914
- return rejectBusy(line);
915
- socketInFlight += 1;
916
- globalInFlight += 1;
917
- processing = processing
1320
+ const concurrent = classifyConcurrentRequest(line, token);
1321
+ const maintenance = concurrent === "heartbeat" && isCurrentHeartbeat(line, supervisor);
1322
+ const media = !maintenance && isMediaRequest(line, token);
1323
+ if (maintenance) {
1324
+ if (
1325
+ socketHeartbeatsInFlight >= MAX_HEARTBEATS_IN_FLIGHT ||
1326
+ globalHeartbeatsInFlight >= MAX_HEARTBEATS_IN_FLIGHT
1327
+ )
1328
+ return rejectBusy(line);
1329
+ socketHeartbeatsInFlight += 1;
1330
+ globalHeartbeatsInFlight += 1;
1331
+ } else {
1332
+ if (socketInFlight >= MAX_SOCKET_IN_FLIGHT || globalInFlight >= MAX_GLOBAL_IN_FLIGHT)
1333
+ return rejectBusy(line);
1334
+ if (
1335
+ media &&
1336
+ (socketMediaInFlight >= MAX_SOCKET_MEDIA_IN_FLIGHT ||
1337
+ globalMediaInFlight >= MAX_GLOBAL_MEDIA_IN_FLIGHT)
1338
+ )
1339
+ return rejectBusy(line);
1340
+ socketInFlight += 1;
1341
+ globalInFlight += 1;
1342
+ if (media) {
1343
+ socketMediaInFlight += 1;
1344
+ globalMediaInFlight += 1;
1345
+ }
1346
+ }
1347
+ // Video admission/result fencing already belongs to each workspace's
1348
+ // policy queue. Another workspace's teardown must not block that lane.
1349
+ const before = concurrent === null ? processing : Promise.resolve();
1350
+ const execution = before
918
1351
  .then(async () => {
919
1352
  const { response, lease } = await handleLine(line, token, supervisor);
920
1353
  if (lease) claimed = lease;
@@ -928,19 +1361,32 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
928
1361
  })
929
1362
  .catch(() => undefined)
930
1363
  .finally(() => {
931
- socketInFlight -= 1;
932
- globalInFlight -= 1;
933
- socket.resume();
1364
+ if (maintenance) {
1365
+ socketHeartbeatsInFlight -= 1;
1366
+ globalHeartbeatsInFlight -= 1;
1367
+ } else {
1368
+ socketInFlight -= 1;
1369
+ globalInFlight -= 1;
1370
+ if (media) {
1371
+ socketMediaInFlight -= 1;
1372
+ globalMediaInFlight -= 1;
1373
+ }
1374
+ }
1375
+ drainBufferedLines();
934
1376
  });
1377
+ // Heartbeats must pass slow page operations, otherwise a healthy bridge
1378
+ // expires and its browser is stopped. Video waits also leave the command
1379
+ // tail. Only the authenticated current lease uses the separately bounded
1380
+ // maintenance lane. Auth, epoch checks and whole ID-keyed writes still apply.
1381
+ if (concurrent === null) processing = execution;
935
1382
  return true;
936
1383
  };
937
1384
 
938
- socket.on("data", (chunk: string) => {
939
- buffer += chunk;
940
- if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) {
941
- socket.destroy();
942
- return;
943
- }
1385
+ // Pausing transport does not consume lines already delivered in its current
1386
+ // chunk. Drain those after each bounded busy response, without replaying a
1387
+ // refused line or creating an unbounded queue of busy-response writes.
1388
+ function drainBufferedLines(): void {
1389
+ if (socket.destroyed || busyResponsePending) return;
944
1390
  let newline = buffer.indexOf("\n");
945
1391
  while (newline >= 0) {
946
1392
  const line = buffer.slice(0, newline);
@@ -948,6 +1394,16 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
948
1394
  if (line.length > 0 && !enqueue(line)) return;
949
1395
  newline = buffer.indexOf("\n");
950
1396
  }
1397
+ socket.resume();
1398
+ }
1399
+
1400
+ socket.on("data", (chunk: string) => {
1401
+ buffer += chunk;
1402
+ if (Buffer.byteLength(buffer) > MAX_MESSAGE_BYTES) {
1403
+ socket.destroy();
1404
+ return;
1405
+ }
1406
+ drainBufferedLines();
951
1407
  });
952
1408
  socket.once("close", () => {
953
1409
  sockets.delete(socket);
@@ -1024,6 +1480,48 @@ export async function startSupervisorServer<Runtime extends RuntimeInstance>(
1024
1480
  },
1025
1481
  };
1026
1482
  }
1483
+ /** Classification already verified the token; stale leases cannot consume reserved capacity. */
1484
+ function isCurrentHeartbeat(line: string, supervisor: RuntimeSupervisor): boolean {
1485
+ const request = parseRuntimeRequest(JSON.parse(line));
1486
+ return (
1487
+ request.method === "bridge.heartbeat" &&
1488
+ supervisor.isCurrentBridgeLease(request.bridgeId, request.epoch)
1489
+ );
1490
+ }
1491
+ /** Capacity-only classification, never an authorization or command-tail bypass. */
1492
+ function isMediaRequest(line: string, token: string): boolean {
1493
+ try {
1494
+ const request = parseRuntimeRequest(JSON.parse(line));
1495
+ if (request.method === "agent.request") return request.operation === "capture";
1496
+ if (!tokensEqual(request.token, token)) return false;
1497
+ if (request.method !== "browser.request" && request.method !== "workspace.request")
1498
+ return false;
1499
+ return (
1500
+ request.operation === "video.read" ||
1501
+ request.operation === (request.method === "browser.request" ? "capture" : "frame")
1502
+ );
1503
+ } catch {
1504
+ return false;
1505
+ }
1506
+ }
1507
+ /** Classify authenticated maintenance/media calls without weakening command ordering. */
1508
+ function classifyConcurrentRequest(line: string, token: string): "heartbeat" | "video" | null {
1509
+ try {
1510
+ const request = parseRuntimeRequest(JSON.parse(line));
1511
+ if (request.method === "agent.request" || !tokensEqual(request.token, token)) return null;
1512
+ if (request.method === "bridge.heartbeat") return "heartbeat";
1513
+ if (request.method !== "browser.request" && request.method !== "workspace.request") return null;
1514
+ if (request.operation !== "video.read") return null;
1515
+ const schema =
1516
+ request.method === "browser.request"
1517
+ ? readBrowserVideoRpc.input
1518
+ : readBrowserVideoRpc.input.omit({ viewerToken: true });
1519
+ return schema.safeParse(request.input).success ? "video" : null;
1520
+ } catch {
1521
+ return null;
1522
+ }
1523
+ }
1524
+
1027
1525
  async function handleLine<Runtime extends RuntimeInstance>(
1028
1526
  line: string,
1029
1527
  token: string,