@omercnet/paseo-shared-browser 1.2.2-next.239.1 → 1.2.2-next.241.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -1,4 +1,4 @@
1
- Shared Browser runs one real Chromium browser per workspace on the daemon host and shows that same live page to every connected Paseo client, so viewers and agents act on one page with one set of cookies and logins. Many viewers can watch, and one human at a time holds control. Open it from the Command Center, the **Shared Browser** composer pill, or, on Paseo 0.11 and newer, the sidebar footer.
1
+ Shared Browser runs one real Chromium browser per workspace on the daemon host and shows that same live page to every connected Paseo client, so viewers and agents act on one page with one set of cookies and logins. Many viewers can watch, and one human at a time holds control. Open it from the Command Center, the **Shared Browser** composer pill, or the sidebar footer.
2
2
 
3
3
  ## How it works
4
4
 
@@ -13,7 +13,7 @@ New agents whose provider accepts external MCP servers receive a local MCP serve
13
13
 
14
14
  ## Setup
15
15
 
16
- - Paseo 0.9, 0.10 or 0.11, and Node.js 24 or newer on the daemon host.
16
+ - Paseo ^0.11.0, and Node.js 24 or newer on the daemon host.
17
17
  - Chromium is downloaded when the plugin is installed and stored in the Paseo data directory. On Linux ARM64 nothing is downloaded, and you install a non-Snap Chromium at `/usr/bin/chromium` first. `PASEO_SHARED_BROWSER_CHROMIUM_EXECUTABLE` points the plugin at a different Chromium.
18
18
 
19
19
  ## Limits
package/README.md CHANGED
@@ -110,7 +110,7 @@ Before upgrading the plugin on Windows, close active Shared Browser sessions and
110
110
  daemon; Windows does not allow the installer to replace runtime executables that are still running.
111
111
 
112
112
  Open a workspace, search the Command Center for **Open Shared Browser**, or tap the **Shared
113
- Browser** composer pill while a workspace session is open. On Paseo 0.11 or newer, a **Shared
113
+ Browser** composer pill while a workspace session is open. A **Shared
114
114
  Browser (n)** row in the sidebar footer appears while browser sessions are open; it lists them and
115
115
  jumps to the chosen workspace's browser panel.
116
116
 
@@ -119,7 +119,7 @@ jumps to the chosen workspace's browser panel.
119
119
  The plugin automatically injects its stdio MCP adapter only when a new, non-internal agent is
120
120
  created with a provider that accepts external MCP servers. Agents that already exist, resumed
121
121
  sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's built-in OMP
122
- provider accepts session MCP servers from Paseo 0.11, so new OMP agents receive the adapter there.
122
+ provider accepts session MCP servers, so new OMP agents receive the adapter.
123
123
  Pi agents also receive the adapter, but require Pi's optional MCP support to
124
124
  launch it.
125
125
 
@@ -417,10 +417,7 @@ runs the actual Paseo compiler on both plugin entries.
417
417
  Release Please maintains the version, changelog, component tag, and GitHub release from
418
418
  Conventional Commits in the monorepo.
419
419
 
420
- Both the Paseo daemon and app must satisfy the version range in `paseo-plugin.json`.
421
- The upstream 0.9, 0.10 and 0.11 ranges remain supported by the manifest;
422
- the tested `0.11.0-beta.3` prerelease is also allowed. Older daemon/client
423
- combinations still need runtime qualification. Encoded video is opt-in on the daemon and
420
+ Both the Paseo daemon and app must satisfy `^0.11.0`. Encoded video is opt-in on the daemon and
424
421
  requires WebCodecs in a web client; JPEG is always available. The client surface uses React
425
422
  Native primitives and works in desktop, web, iOS, and Android Paseo clients.
426
423
 
@@ -69,7 +69,12 @@ admit clicks indefinitely. Motion, releases and keys of a held gesture use the o
69
69
  channel without waiting for new pixels. Agent commands retain strict fresh-frame
70
70
  targeting.
71
71
 
72
- A visible frame is not automatically an actionable receipt. Delayed reads may
72
+ A visible frame is not automatically an actionable receipt. The host flags each
73
+ video packet `actionable` or not (a capture older than the last acknowledged input
74
+ is painted but never a receipt), and the viewer starts a new epoch when the host
75
+ acknowledges a receipt-revoking input, so reads answered before that revocation
76
+ cannot authorize the next press. After an acknowledged press the viewer has no
77
+ actionable video receipt until a newer packet paints; a following press waits for it. Delayed reads may
73
78
  continue painting within the same source and geometry while retaining their
74
79
  original input revision. Initial-admission waiters wake only for actionable
75
80
  pixels. A document or viewport transition, controller loss, cancellation,
@@ -60,6 +60,7 @@ function fixture(
60
60
  const errors: unknown[] = [];
61
61
  const cursors: unknown[] = [];
62
62
  const navigationCompleted: boolean[] = [];
63
+ const acknowledgements: string[] = [];
63
64
  const states: BrowserState[] = [];
64
65
  let projectState: ((state: BrowserState) => void) | null = null;
65
66
  let finished = 0;
@@ -94,6 +95,7 @@ function fixture(
94
95
  finished += 1;
95
96
  },
96
97
  onNavigationComplete: () => navigationCompleted.push(true),
98
+ onAcknowledged: () => acknowledgements.push("ack"),
97
99
  ...(waitForFrame ? { waitForFrame } : {}),
98
100
  });
99
101
  return {
@@ -105,6 +107,7 @@ function fixture(
105
107
  beginGate,
106
108
  transport,
107
109
  navigationCompleted,
110
+ acknowledgements,
108
111
  states,
109
112
  finishes: () => finished,
110
113
  projectState(callback: (state: BrowserState) => void) {
@@ -1062,6 +1065,56 @@ it.each(["viewer", "controller", "session", "runtime", "bridge", "document", "vi
1062
1065
  },
1063
1066
  );
1064
1067
 
1068
+ it("reports host acknowledgement of every receipt-revoking input, never plain hover", async () => {
1069
+ const f = fixture(undefined, true);
1070
+ f.queue.enqueue(move(1));
1071
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
1072
+ await flush();
1073
+ f.queue.enqueue(move(2));
1074
+ await flush();
1075
+ expect(f.acknowledgements).toEqual([]);
1076
+ f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 });
1077
+ await flush();
1078
+ expect(f.acknowledgements).toEqual(["ack"]);
1079
+ f.queue.enqueue(move(3)); // a held drag keeps revoking
1080
+ f.queue.enqueue({ kind: "up", button: "left", point: point(3), clickCount: 1 });
1081
+ await flush();
1082
+ expect(f.acknowledgements).toHaveLength(3);
1083
+ f.queue.enqueue({ kind: "scroll", point: point(3), deltaX: 0, deltaY: 5 });
1084
+ await flush();
1085
+ expect(f.acknowledgements).toHaveLength(4);
1086
+ f.queue.cancel();
1087
+ });
1088
+
1089
+ it("waits for the next decoded frame when video revoked its receipt, then presses with that frame", async () => {
1090
+ // Video input authority disappears (null) right after an acknowledged press and
1091
+ // returns only when the next packet paints; the queue must wait, not refuse.
1092
+ let waited = 0;
1093
+ const f: ReturnType<typeof fixture> = fixture(async (afterFrameId) => {
1094
+ waited += 1;
1095
+ expect(afterFrameId).toBe(authority.target.frameId);
1096
+ f.changeAuthority(freshAuthority);
1097
+ }, true);
1098
+ f.queue.enqueue({ kind: "down", button: "left", point: point(1), clickCount: 1 });
1099
+ f.beginGate.resolve({ state, gestureId: "opaque", nextSequence: 1 });
1100
+ await flush();
1101
+ f.queue.enqueue({ kind: "up", button: "left", point: point(1), clickCount: 1 });
1102
+ await flush();
1103
+ f.changeAuthority(null);
1104
+ expect(f.queue.enqueue({ kind: "down", button: "left", point: point(2), clickCount: 1 })).toBe(
1105
+ true,
1106
+ );
1107
+ await flush();
1108
+ expect(waited).toBe(1);
1109
+ expect(f.errors).toEqual([]);
1110
+ expect(f.sent.map((input) => input.event.kind)).toEqual(["down", "up", "down"]);
1111
+ expect(f.sent.map((input) => input.target?.frameId)).toEqual([
1112
+ authority.target.frameId,
1113
+ undefined,
1114
+ freshAuthority.target.frameId,
1115
+ ]);
1116
+ f.queue.cancel();
1117
+ });
1065
1118
  it("gives each independent touch contact its own decoded receipt; fingers within a gesture and text reuse the channel", async () => {
1066
1119
  const f: ReturnType<typeof fixture> = fixture(async () => {
1067
1120
  f.changeAuthority(freshAuthority);
@@ -52,6 +52,13 @@ interface QueueOptions {
52
52
  onFinish(): void;
53
53
  /** Reset local held contacts after an acknowledged navigation, without cancelling this queue again. */
54
54
  onNavigationComplete?(): void;
55
+ /**
56
+ * Called after the host acknowledges a non-hover event. Receipts decoded from
57
+ * anything requested before this point predate the input's effect, so the viewer
58
+ * must stop treating them as actionable even if they were requested after the
59
+ * local press began.
60
+ */
61
+ onAcknowledged?(): void;
55
62
  /** Resolves only after a different actionable decoded receipt commits, never visual-only pixels or a timer. */
56
63
  waitForFrame?(afterFrameId: string, maxWaitMs?: number): Promise<void>;
57
64
  }
@@ -336,11 +343,17 @@ export function createBrowserInputQueue(options: QueueOptions) {
336
343
  let pressTarget: BrowserGestureAuthority["target"] | undefined;
337
344
  if (needsFreshPress(command.event, owned)) {
338
345
  let fresh = options.authority();
339
- if (fresh && invalidatedFrameId === fresh.target.frameId && options.waitForFrame) {
340
- // Earlier input revoked the visible receipt. Wait a bounded time for a
341
- // newer decoded one; elapsed time alone never admits a press, and the
346
+ // Video revokes its receipt as soon as input is acknowledged, leaving no
347
+ // actionable authority until the next frame paints; JPEG keeps the spent
348
+ // frame visible. Both mean this press needs a newer decoded receipt.
349
+ const visibleId = fresh?.target.frameId ?? owned.authority.target.frameId;
350
+ const spent = fresh
351
+ ? invalidatedFrameId === fresh.target.frameId
352
+ : invalidatedFrameId !== null;
353
+ if (spent && options.waitForFrame) {
354
+ // Wait a bounded time; elapsed time alone never admits a press, and the
342
355
  // server refuses a spent or expired receipt regardless.
343
- await options.waitForFrame(fresh.target.frameId);
356
+ await options.waitForFrame(visibleId);
344
357
  if (currentEpoch !== epoch) break;
345
358
  fresh = options.authority();
346
359
  }
@@ -349,7 +362,7 @@ export function createBrowserInputQueue(options: QueueOptions) {
349
362
  throw new Error("Browser input context changed. Release the gesture and try again.");
350
363
  }
351
364
  pressTarget = fresh.target;
352
- } else if (owned.authority.target.frameId !== invalidatedFrameId) {
365
+ } else if (!spent) {
353
366
  // Display was revoked after admission but before this first press; the
354
367
  // receipt begin just validated is still unspent. The server re-checks it.
355
368
  pressTarget = owned.authority.target;
@@ -367,10 +380,10 @@ export function createBrowserInputQueue(options: QueueOptions) {
367
380
  if (command.event.kind === "down") owned.heldButtons.add(command.event.button);
368
381
  if (command.event.kind === "up") owned.heldButtons.delete(command.event.button);
369
382
  // Mirror the server: everything except hover revokes the visible receipt.
370
- if (
383
+ const revokesReceipts =
371
384
  (command.event.kind !== "move" && command.event.kind !== "leave") ||
372
- owned.heldButtons.size > 0
373
- ) {
385
+ owned.heldButtons.size > 0;
386
+ if (revokesReceipts) {
374
387
  invalidatedFrameId =
375
388
  options.authority()?.target.frameId ?? owned.authority.target.frameId;
376
389
  }
@@ -413,6 +426,7 @@ export function createBrowserInputQueue(options: QueueOptions) {
413
426
  if (!sameAuthority(owned.authority, currentControl())) {
414
427
  throw new Error("Browser input context changed. Release the gesture and try again.");
415
428
  }
429
+ if (revokesReceipts) options.onAcknowledged?.();
416
430
  options.onCursor(result.cursor);
417
431
  }
418
432
  } catch (error) {
@@ -0,0 +1,45 @@
1
+ /** Source, document and geometry validity of a viewer frame, shared by the panel and its tests. */
2
+ import type { BrowserState } from "../shared/browser";
3
+ import type { BrowserFrameAuthority, BrowserVideoPacket } from "../shared/browser-video";
4
+
5
+ export function isFrameCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
6
+ return (
7
+ frame.sessionId === state.sessionId &&
8
+ (!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
9
+ (frame.captureEpoch === undefined ||
10
+ state.bridgeEpoch === undefined ||
11
+ frame.captureEpoch === state.bridgeEpoch) &&
12
+ frame.navigationGeneration === state.navigationGeneration &&
13
+ frame.viewportGeneration === state.viewportGeneration
14
+ );
15
+ }
16
+
17
+ export interface VideoSourceContext {
18
+ mounted: boolean;
19
+ viewerActive: boolean;
20
+ epoch: number;
21
+ committedViewport: { sessionId: string; generation: number } | null;
22
+ state: BrowserState | null;
23
+ }
24
+
25
+ /**
26
+ * Whether a packet may be decoded and painted. This is source validity only: a
27
+ * display-only packet (actionable === false) is still a link in the codec's delta
28
+ * chain, so the input-only flag must never be consulted here. Input admission is
29
+ * the video hook's separate inputCurrent guard.
30
+ */
31
+ export function isVideoSourceCurrent(
32
+ context: VideoSourceContext,
33
+ packet: BrowserVideoPacket,
34
+ epoch: number,
35
+ ): boolean {
36
+ return (
37
+ context.mounted &&
38
+ context.viewerActive &&
39
+ epoch === context.epoch &&
40
+ context.committedViewport?.sessionId === packet.frame.sessionId &&
41
+ context.committedViewport?.generation === packet.frame.viewportGeneration &&
42
+ context.state !== null &&
43
+ isFrameCurrent(packet.frame, context.state)
44
+ );
45
+ }
@@ -84,6 +84,7 @@ import {
84
84
  BrowserMenuSeparator,
85
85
  BrowserToolbarMenu,
86
86
  } from "./browser-toolbar-menu";
87
+ import { isFrameCurrent, isVideoSourceCurrent } from "./browser-video-currency";
87
88
  import { BrowserVideoSurface } from "./browser-video-surface";
88
89
  import { isBrowserClosedError, isExpiredBrowserViewerError } from "./browser-viewer-recovery";
89
90
  import { createFrameLifecycle } from "./frame-lifecycle";
@@ -182,18 +183,6 @@ function isFrameDisplayCurrent(frame: BrowserFrameAuthority, state: BrowserState
182
183
  );
183
184
  }
184
185
 
185
- function isFrameCurrent(frame: BrowserFrameAuthority, state: BrowserState): boolean {
186
- return (
187
- frame.sessionId === state.sessionId &&
188
- (!frame.runtimeId || !state.runtimeId || frame.runtimeId === state.runtimeId) &&
189
- (frame.captureEpoch === undefined ||
190
- state.bridgeEpoch === undefined ||
191
- frame.captureEpoch === state.bridgeEpoch) &&
192
- frame.navigationGeneration === state.navigationGeneration &&
193
- frame.viewportGeneration === state.viewportGeneration
194
- );
195
- }
196
-
197
186
  export function SharedBrowserPanel({
198
187
  theme,
199
188
  host,
@@ -505,13 +494,17 @@ export function SharedBrowserPanel({
505
494
  epoch: () => inputLifecycle.epoch,
506
495
  viewport: () => stateRef.current?.viewport ?? null,
507
496
  isCurrent: (packet, epoch) =>
508
- mountedRef.current &&
509
- activeViewerTokenRef.current === viewerToken &&
510
- epoch === inputLifecycle.epoch &&
511
- committedViewport.current?.sessionId === packet.frame.sessionId &&
512
- committedViewport.current?.generation === packet.frame.viewportGeneration &&
513
- stateRef.current !== null &&
514
- isFrameCurrent(packet.frame, stateRef.current),
497
+ isVideoSourceCurrent(
498
+ {
499
+ mounted: mountedRef.current,
500
+ viewerActive: activeViewerTokenRef.current === viewerToken,
501
+ epoch: inputLifecycle.epoch,
502
+ committedViewport: committedViewport.current,
503
+ state: stateRef.current,
504
+ },
505
+ packet,
506
+ epoch,
507
+ ),
515
508
  isDisplayCurrent: (packet) => {
516
509
  const current = stateRef.current;
517
510
  return (
@@ -962,6 +955,11 @@ export function SharedBrowserPanel({
962
955
  lastPointRef.current = { x: point.x, y: point.y };
963
956
  },
964
957
  onActivity: handleInputActivity,
958
+ onInputAcknowledged: () => {
959
+ // Pixels requested before the host acknowledged this input cannot authorize
960
+ // the next press, even when their request began after the local press.
961
+ inputLifecycle.bump();
962
+ },
965
963
  onInputBoundary: () => {
966
964
  inputLifecycle.bump();
967
965
  },
@@ -123,9 +123,8 @@ function createSidebarItem(client: PluginClientContext) {
123
123
  };
124
124
  }
125
125
 
126
- /** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
126
+ /** Sidebar footer row listing open sessions. */
127
127
  export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
128
- if (typeof client.addSidebarFooterItem !== "function") return () => {};
129
128
  return client.addSidebarFooterItem({
130
129
  id: "shared-browser-sessions",
131
130
  title: "Shared Browser",
@@ -45,6 +45,8 @@ interface CanvasOptions {
45
45
  onPoint(point: BrowserGesturePoint): void;
46
46
  onActivity(active: boolean): void;
47
47
  onInputBoundary(): void;
48
+ /** The host acknowledged an input that revoked every earlier frame receipt. */
49
+ onInputAcknowledged?(): void;
48
50
  }
49
51
 
50
52
  /** Native touch coordinates are relative to the controlled overlay, with stable ID mapping. */
@@ -120,6 +122,9 @@ export function useBrowserCanvasInput(options: CanvasOptions) {
120
122
  clearIdle();
121
123
  if (alive.current) current.current.onError(error);
122
124
  },
125
+ onAcknowledged: () => {
126
+ if (alive.current) current.current.onInputAcknowledged?.();
127
+ },
123
128
  onNavigationComplete: () => {
124
129
  // Server already closed the acknowledged channel. Quarantine local
125
130
  // holds before state projection; never replay their old-page release.
@@ -1,10 +1,12 @@
1
1
  /** Deliberately defer React commits to exercise canvas paint and RPC replacement windows. */
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { BrowserState } from "../shared/browser";
3
4
  import type {
4
5
  BrowserVideoPacket,
5
6
  BrowserVideoReadInput,
6
7
  BrowserVideoReadReply,
7
8
  } from "../shared/browser-video";
9
+ import { isVideoSourceCurrent } from "./browser-video-currency";
8
10
  import type { BrowserVideoDecodeEnvironment } from "./browser-video-decoder";
9
11
 
10
12
  interface HookSlot {
@@ -211,11 +213,30 @@ function setup(retainDocumentDisplay = false) {
211
213
  fps,
212
214
  epoch: () => mutationEpoch,
213
215
  viewport: () => ({ width: 1280, height: 800 }),
216
+ // The mounted panel's real source predicate, fed a fake panel context, so a drift
217
+ // in what the panel decides (for example consulting the input-only actionable flag)
218
+ // fails here instead of in a copied predicate.
214
219
  isCurrent: (value, epoch) =>
215
220
  current &&
216
- epoch === mutationEpoch &&
217
- Object.entries(identity).every(
218
- ([key, expected]) => value.frame[key as keyof typeof identity] === expected,
221
+ isVideoSourceCurrent(
222
+ {
223
+ mounted: true,
224
+ viewerActive: true,
225
+ epoch: mutationEpoch,
226
+ committedViewport: {
227
+ sessionId: identity.sessionId,
228
+ generation: identity.viewportGeneration,
229
+ },
230
+ state: {
231
+ sessionId: identity.sessionId,
232
+ runtimeId: identity.runtimeId,
233
+ bridgeEpoch: identity.captureEpoch,
234
+ navigationGeneration: identity.navigationGeneration,
235
+ viewportGeneration: identity.viewportGeneration,
236
+ } as unknown as BrowserState,
237
+ },
238
+ value,
239
+ epoch,
219
240
  ),
220
241
  ...(retainDocumentDisplay
221
242
  ? {
@@ -278,6 +299,19 @@ function setup(retainDocumentDisplay = false) {
278
299
  packets: [{ ...packet(sequence), type: keyFrame ? "key" : "delta" }],
279
300
  } as BrowserVideoReadReply);
280
301
  },
302
+ replyPackets(index: number, sequences: number[], display: number[] = []) {
303
+ harness.reads[index]!.resolve({
304
+ status: "ready",
305
+ streamId: "s".repeat(32),
306
+ state: {},
307
+ packets: sequences.map((sequence) => ({
308
+ ...packet(sequence),
309
+ type: sequence === 1 ? "key" : "delta",
310
+ // The host paints these but refuses them as input receipts.
311
+ ...(display.includes(sequence) ? { actionable: false } : { actionable: true }),
312
+ })),
313
+ } as BrowserVideoReadReply);
314
+ },
281
315
  disabled(index: number) {
282
316
  harness.reads[index]!.resolve({
283
317
  status: "unsupported",
@@ -521,6 +555,41 @@ describe("video presentation lifecycle", () => {
521
555
  expect(result.error).toBeNull();
522
556
  expect(result.frontRef.current).not.toBeNull();
523
557
  });
558
+ it("decodes and paints a same-source display-only packet without breaking the delta chain or granting input", async () => {
559
+ const f = setup();
560
+ const front: (number | null)[] = [];
561
+ for (const sequence of [1, 2, 3]) {
562
+ f.replyPackets(sequence - 1, [sequence], [2]);
563
+ await drain();
564
+ f.paint();
565
+ const result = f.render();
566
+ f.commit();
567
+ front.push(result.frontRef.current?.sequence ?? null);
568
+ await vi.advanceTimersByTimeAsync(41);
569
+ }
570
+ // All three packets paint, so the codec chain 1 -> 2 -> 3 is intact.
571
+ expect(f.drawn).toEqual([1000, 2000, 3000]);
572
+ // The display-only packet never publishes input authority and does not leave the
573
+ // older actionable receipt current; the next actionable packet does.
574
+ expect(f.presented).not.toContain(2);
575
+ expect(f.presented).toContain(3);
576
+ expect(front).toEqual([1, null, 3]);
577
+ // Each read continued the same stream without asking for a recovery keyframe.
578
+ expect(harness.reads.slice(1, 4).map((read) => read.input.requestKeyFrame)).toEqual([
579
+ false,
580
+ false,
581
+ false,
582
+ ]);
583
+ });
584
+ it("still drops wrong-document display-only packets without decoding or painting them", async () => {
585
+ const f = setup();
586
+ f.identity("navigationGeneration", 2);
587
+ f.replyPackets(0, [1, 2], [2]);
588
+ await drain();
589
+ f.paint();
590
+ expect(f.drawn).toEqual([]);
591
+ expect(f.presented).toEqual([]);
592
+ });
524
593
  it("a host that disabled video stops reads and releases the decoder without an error", async () => {
525
594
  const f = setup();
526
595
  f.disabled(0);
@@ -71,6 +71,13 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
71
71
  fallbackRef.current = ++fallbackCounter.current;
72
72
  setFallbackRevision(fallbackRef.current);
73
73
  };
74
+ /** Source/document/geometry validity: enough to decode and paint a packet. */
75
+ const sourceCurrent = (packet: BrowserVideoPacket, epoch: number) =>
76
+ latest.current.isCurrent(packet, epoch);
77
+ /** Input eligibility: source validity plus a host receipt. A display-only packet
78
+ * (actionable === false) may be decoded and painted but never admits a press. */
79
+ const inputCurrent = (packet: BrowserVideoPacket, epoch: number) =>
80
+ packet.actionable !== false && sourceCurrent(packet, epoch);
74
81
  const displayCurrent = (packet: BrowserVideoPacket) =>
75
82
  latest.current.isDisplayCurrent
76
83
  ? latest.current.isDisplayCurrent(packet)
@@ -78,7 +85,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
78
85
  const front = presentation?.packet ?? null;
79
86
  const setFront = (value: BrowserVideoPacket | null, epoch = 0) => {
80
87
  displayedPacket.current = value;
81
- if (!value || latest.current.isCurrent(value, epoch)) {
88
+ if (!value || inputCurrent(value, epoch)) {
82
89
  fallbackRef.current = 0;
83
90
  setFallbackRevision(0);
84
91
  }
@@ -121,7 +128,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
121
128
  if (
122
129
  !fallbackRef.current &&
123
130
  presentation.authorityRevision === authorityRevision.current &&
124
- latest.current.isCurrent(presentation.packet, presentation.epoch)
131
+ inputCurrent(presentation.packet, presentation.epoch)
125
132
  ) {
126
133
  frontRef.current = presentation.packet;
127
134
  latest.current.onPresented(presentation.packet, presentation.epoch);
@@ -167,9 +174,9 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
167
174
  isCurrent: (packet, epoch) => !stopped && latest.current.isCurrent(packet, epoch),
168
175
  // Same source/document packets preserve the codec's dependency chain,
169
176
  // including a reply that began before a local press. They carry that old
170
- // read epoch through paint and cannot authorize another press.
171
- isSourceCurrent: (packet) =>
172
- !stopped && latest.current.isCurrent(packet, latest.current.epoch()),
177
+ // read epoch through paint and cannot authorize another press. A display-only
178
+ // (non-actionable) packet is still source-valid and must keep decoding.
179
+ isSourceCurrent: (packet) => !stopped && sourceCurrent(packet, latest.current.epoch()),
173
180
  onPresented(packet, epoch) {
174
181
  if (stopped) return;
175
182
  lastPaintedAt = Date.now();
@@ -177,7 +184,7 @@ export function useBrowserVideo(options: BrowserVideoOptions) {
177
184
  setError(null);
178
185
  // Once this canvas is visible, its pixels change before React commits.
179
186
  // Publish authority synchronously with that draw, never the old front.
180
- if (visible.current && latest.current.isCurrent(packet, epoch)) {
187
+ if (visible.current && inputCurrent(packet, epoch)) {
181
188
  frontRef.current = packet;
182
189
  latest.current.onPresented(packet, epoch);
183
190
  } else {
package/index.server.ts CHANGED
@@ -109,12 +109,8 @@ export default function contribute(server: PluginServerContext) {
109
109
  server.handle(resizeBrowserRpc, handleResizeBrowser);
110
110
  server.handle(applyDevicePresetRpc, handleApplyDevicePreset);
111
111
  server.handle(sendBrowserInputRpc, handleSendBrowserInput);
112
- // OMP accepts session MCP servers from Paseo 0.11.0-beta.1, the release that added
113
- // registerUsageSource; 0.9/0.10 reject agent creation when an OMP config carries MCP servers.
114
- const ompAcceptsMcp = typeof server.registerUsageSource === "function";
115
112
  server.before("agent.create", async ({ request }) => {
116
113
  if (request.config.internal) return request;
117
- if (request.config.provider === "omp" && !ompAcceptsMcp) return request;
118
114
  const ticket = randomBytes(32).toString("base64url");
119
115
  if (!(await issueTicketWithinDeadline(ticket))) return request;
120
116
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.2.2-next.239.1",
3
+ "version": "1.2.2-next.241.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
package/paseo-plugin.json CHANGED
@@ -1,8 +1,6 @@
1
1
  {
2
2
  "id": "shared-browser",
3
3
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
4
- "requirements": {
5
- "paseo": "^0.9.0 || ^0.10.0 || ^0.11.0 || 0.11.0-beta.3 || 0.11.0-beta.4"
6
- },
4
+ "requirements": { "paseo": "^0.11.0" },
7
5
  "build": [["node", "scripts/prepare-dependencies.mjs"], ["npm", "run", "prepare:runtime"]]
8
6
  }
@@ -749,8 +749,10 @@ export class SessionManager {
749
749
  };
750
750
  session.videoReceipts.set(receipt, { frame, capturedAtMonotonicMs });
751
751
  }
752
- this.rememberFrame(session, frame, capturedAtMonotonicMs);
753
- packets.push({ ...packet, frame });
752
+ // False when input revoked this capture's time: it may still be painted,
753
+ // but it can never authorize a press, and the viewer must not try.
754
+ const actionable = this.rememberFrame(session, frame, capturedAtMonotonicMs);
755
+ packets.push({ ...packet, frame, actionable });
754
756
  }
755
757
  }
756
758
  while (session.videoReceipts.size > MAX_RECENT_FRAMES) {
@@ -1746,12 +1748,12 @@ export class SessionManager {
1746
1748
  frame: BrowserFrameAuthority,
1747
1749
  capturedAtMonotonicMs?: number,
1748
1750
  observedAt = this.now(),
1749
- ): void {
1751
+ ): boolean {
1750
1752
  if (capturedAtMonotonicMs !== undefined && session.videoInputNotBefore !== null) {
1751
1753
  const exclusiveFloor = session.videoInputNotBefore + VIDEO_SOURCE_CLOCK_TOLERANCE_MS;
1752
1754
  if (!Number.isFinite(capturedAtMonotonicMs) || capturedAtMonotonicMs <= exclusiveFloor) {
1753
1755
  session.recentFrames.delete(frame.frameId);
1754
- return;
1756
+ return false;
1755
1757
  }
1756
1758
  }
1757
1759
  this.pruneRecentFrames(session);
@@ -1770,6 +1772,7 @@ export class SessionManager {
1770
1772
  if (!oldest) break;
1771
1773
  session.recentFrames.delete(oldest);
1772
1774
  }
1775
+ return session.recentFrames.has(frame.frameId);
1773
1776
  }
1774
1777
 
1775
1778
  /**
@@ -153,6 +153,8 @@ it.each([-1, 0, 40])(
153
153
  const late = await pendingRead;
154
154
  pendingRead = null;
155
155
  expect(late.packets[0]!.frame.frameId).toBe(frame.frameId);
156
+ // Late pixels are painted but flagged: the viewer must not try to press on them.
157
+ expect(late.packets[0]!.actionable).toBe(false);
156
158
  // Late pixels still paint, but cannot re-authorize discrete agent input.
157
159
  await expect(strictInput()).rejects.toThrow("frame is stale");
158
160
  expect(calls).not.toContain("mouse.down");
@@ -185,6 +187,7 @@ it.each([-1, 0, 40])(
185
187
  packet.capturedAt = new Date(now).toISOString();
186
188
  const fresh = await manager.readVideo(videoInput);
187
189
  const freshFrame = fresh.packets[0]!.frame;
190
+ expect(fresh.packets[0]!.actionable).toBe(true);
188
191
  const accepted = await manager.beginGesture({
189
192
  viewerToken,
190
193
  controlToken,
@@ -52,6 +52,9 @@ export type NativeVideoPacket = z.infer<typeof nativeVideoPacketSchema>;
52
52
 
53
53
  export const browserVideoPacketSchema = nativeVideoPacketSchema.extend({
54
54
  frame: browserFrameAuthoritySchema,
55
+ /** False when the host refused this capture as an input receipt (it predates
56
+ * acknowledged input). It may still be painted. Absent from older hosts. */
57
+ actionable: z.boolean().optional(),
55
58
  });
56
59
  export type BrowserVideoPacket = z.infer<typeof browserVideoPacketSchema>;
57
60