juce-webview-agent-bridge 0.4.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ commit comparisons are available from the linked GitHub Releases.
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.1] - 2026-07-19
9
+
10
+ ### Fixed
11
+
12
+ - The error raised when the host's module lacks a required op no longer tells
13
+ you to bump a `FetchContent` `GIT_TAG`. Projects that embed the module as a
14
+ git submodule or a vendored copy have no such pin, so the advice sent them
15
+ looking for something their build does not contain.
16
+ - `scripts/release.sh` now refuses to release until `CHANGELOG.md` documents the
17
+ version being released, checked before it modifies anything. v0.5.0 shipped
18
+ with its entries still under `[Unreleased]` and comparison links pointing at
19
+ the previous release; every other release step was already self-verifying.
20
+ The release runbook is now written up in `docs/releasing.md`.
21
+
22
+ ## [0.5.0] - 2026-07-19
23
+
24
+ ### Added
25
+
26
+ - The `hello` reply now reports `moduleVersion`, the version of the C++ module
27
+ the host embeds. `protocolVersion` only moves on a breaking change, so it
28
+ could not identify a plugin built against an older module pin.
29
+ - Both clients now negotiate capabilities against the `hello` handshake. An API
30
+ that needs an op the host does not provide fails immediately with an error
31
+ naming the host module version, the client version, and the fix (bump the
32
+ plugin's `GIT_TAG` and rebuild) instead of the host's bare `unknown op`.
33
+ Connecting to a host that advertises a newer protocol major now fails outright
34
+ rather than misbehaving later.
35
+
36
+ ### Fixed
37
+
38
+ - `page.replayEvents()` now surfaces a failed `sink_replay` reply as an error
39
+ instead of silently resolving with an undefined count.
40
+ - `connect()` now fails when the host rejects the page-helper injection, instead
41
+ of returning a session whose every locator then failed for unclear reasons.
42
+ A failed authentication also surfaces here rather than passing silently.
43
+ - `connect()` no longer leaves the connection open when initialization fails.
44
+
45
+ ### Changed
46
+
47
+ - The CLI performs the handshake once per run, so a host advertising a newer
48
+ protocol major is refused before the command runs rather than part-way through.
49
+ `ping` and `hello` are exempt: they are the diagnostics you need in order to
50
+ see a version mismatch at all.
51
+ - `page.capabilities()` returns the handshake taken during `connect()` (it
52
+ cannot change within a connection) instead of issuing a second `hello`.
53
+ `page.caps` exposes the same value, or `null` against a host too old to answer
54
+ the handshake — in which case every capability guard stands down, so existing
55
+ setups keep working unchanged.
56
+
8
57
  ## [0.4.0] - 2026-07-18
9
58
 
10
59
  ### Added
@@ -82,7 +131,9 @@ There are no package API changes in this release.
82
131
  - Zero-dependency CLI, Playwright-shaped E2E client, agent skill, and standalone
83
132
  JavaScript and C++ test suites.
84
133
 
85
- [Unreleased]: https://github.com/mateusz28011/juce-webview-agent-bridge/compare/v0.4.0...HEAD
134
+ [Unreleased]: https://github.com/mateusz28011/juce-webview-agent-bridge/compare/v0.5.1...HEAD
135
+ [0.5.1]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.5.1
136
+ [0.5.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.5.0
86
137
  [0.4.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.4.0
87
138
  [0.3.1]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.3.1
88
139
  [0.3.0]: https://github.com/mateusz28011/juce-webview-agent-bridge/releases/tag/v0.3.0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # juce_webview_agent_bridge
1
+ # JUCE WebView Agent Bridge
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/juce-webview-agent-bridge.svg)](https://www.npmjs.com/package/juce-webview-agent-bridge)
4
4
  [![tests](https://github.com/mateusz28011/juce-webview-agent-bridge/actions/workflows/tests.yml/badge.svg)](https://github.com/mateusz28011/juce-webview-agent-bridge/actions/workflows/tests.yml)
@@ -89,7 +89,7 @@ or with CMake `FetchContent` (after JUCE has been added, so
89
89
  include(FetchContent)
90
90
  FetchContent_Declare(juce_webview_agent_bridge
91
91
  GIT_REPOSITORY https://github.com/mateusz28011/juce-webview-agent-bridge.git
92
- GIT_TAG v0.4.0)
92
+ GIT_TAG v0.5.1)
93
93
  FetchContent_MakeAvailable(juce_webview_agent_bridge)
94
94
  ```
95
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "juce-webview-agent-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Typed, zero-dependency E2E client and CLI for driving live JUCE WebViews over a debug-only loopback bridge, without CDP.",
5
5
  "keywords": [
6
6
  "juce",
package/tools/e2e.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Socket } from 'node:net';
2
+ import type { BridgeCapabilities } from './shared.mjs';
2
3
  type ProtocolMessage = Record<string, any>;
3
4
  type LogFn = (message: string) => void;
4
5
  type TimeoutOptions = {
@@ -43,13 +44,10 @@ export type NetworkEventData = {
43
44
  status?: number;
44
45
  [key: string]: unknown;
45
46
  };
46
- export type Capabilities = {
47
- protocolVersion: number;
48
- platform: string;
49
- ops: string[];
50
- screenshotAvailable: boolean;
51
- authRequired: boolean;
52
- };
47
+ /** The `hello` handshake. Shape owned by shared.mts (both clients negotiate
48
+ against it); re-exported here under the name this client has always used. */
49
+ export type Capabilities = BridgeCapabilities;
50
+ export type { BridgeCapabilities };
53
51
  export type RenderPerfResult = {
54
52
  durMs: number;
55
53
  commitsPerSec: number;
@@ -162,12 +160,16 @@ export declare class Page {
162
160
  readonly interval: number;
163
161
  readonly backendTimeoutMs: number;
164
162
  readonly log: LogFn;
163
+ /** The `hello` handshake taken at connect(), or null when the host could not
164
+ answer it. Immutable for the life of the connection (one host process). */
165
+ readonly caps: BridgeCapabilities | null;
165
166
  logFile: string | null;
166
- constructor(session: Session, { defaultTimeout, interval, log, backendTimeoutMs }: {
167
+ constructor(session: Session, { defaultTimeout, interval, log, backendTimeoutMs, caps }: {
167
168
  defaultTimeout: number;
168
169
  interval: number;
169
170
  log: LogFn;
170
171
  backendTimeoutMs?: number;
172
+ caps?: BridgeCapabilities | null;
171
173
  });
172
174
  locator(selector: string): Locator;
173
175
  getByTestId(id: string): Locator;
@@ -241,8 +243,10 @@ export declare class Page {
241
243
  motionSelector?: string | null;
242
244
  }): Promise<RenderPerfResult>;
243
245
  /** Capabilities handshake: { protocolVersion, platform, ops, screenshotAvailable,
244
- authRequired }. Lets a caller branch on what the host supports (e.g. skip
245
- screenshots when screenshotAvailable is false) without probing op-by-op. */
246
+ authRequired, moduleVersion? }. Lets a caller branch on what the host supports
247
+ (e.g. skip screenshots when screenshotAvailable is false) without probing
248
+ op-by-op. Returns the handshake connect() already took — it cannot change
249
+ within a connection — and only asks again if that one did not land. */
246
250
  capabilities(): Promise<Capabilities>;
247
251
  /** Toggle WebKit's compositing debug overlays (layer borders + repaint
248
252
  counters) on the host's WKWebView via the bridge `layerdebug` op. The
@@ -356,4 +360,3 @@ export interface ExpectFunction {
356
360
  poll<T>(fn: () => T | Promise<T>, options?: PollExpectOptions): PollAssertions<T>;
357
361
  }
358
362
  export declare const expect: ExpectFunction;
359
- export {};
package/tools/e2e.mjs CHANGED
@@ -31,7 +31,7 @@ import net from 'node:net';
31
31
  import fs from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
- import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
34
+ import { DEFAULT_PORT, assertProtocolSupported, loadDiscovery, onJsonLines, parseHello, requireOp } from './shared.mjs';
35
35
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
36
36
  /** Bring the host app's window to the foreground (macOS, best-effort; resolves
37
37
  * false elsewhere). A backgrounded WebView reports document.hidden === true and
@@ -450,6 +450,21 @@ class Session {
450
450
  }
451
451
  catch { } }
452
452
  }
453
+ /** Run the `hello` handshake and validate the protocol major.
454
+ Null means "capabilities unknown" — a host too old to answer `hello` must
455
+ stay usable exactly as before, so every guard stands down.
456
+
457
+ A transport failure is NOT folded into null: the reference host always
458
+ answers unknown ops with `ok:false`, so an exception here means the
459
+ connection is broken, and swallowing it would silently disable the guards
460
+ for the rest of the session. Validating here (rather than at the call site)
461
+ keeps the retry in capabilities() from returning an unvalidated host. */
462
+ async function handshake(session) {
463
+ const caps = parseHello(await session.request({ op: 'hello' }));
464
+ if (caps)
465
+ assertProtocolSupported(caps);
466
+ return caps;
467
+ }
453
468
  // ---- public API -----------------------------------------------------------
454
469
  export async function connect({ host = '127.0.0.1', port, token, timeout = 5000, interval = 50, log, logFile, logEcho, backendTimeoutMs = 10000, activate } = {}) {
455
470
  // Foreground the host window first (see activateApp) so polling/timers are live
@@ -472,10 +487,29 @@ export async function connect({ host = '127.0.0.1', port, token, timeout = 5000,
472
487
  });
473
488
  sock.unref(); // the persistent client socket must not, by itself, keep node alive
474
489
  const session = new Session(sock, resolvedToken);
475
- if (resolvedToken)
476
- await session.request({ op: 'auth', token: resolvedToken });
477
- await session.request({ op: 'eval', code: PAGE_HELPERS }); // inject helpers once
478
- const page = new Page(session, { defaultTimeout: timeout, interval, log: logger, backendTimeoutMs });
490
+ // Every failure past this point must close the socket before rethrowing:
491
+ // unref() keeps the client socket from holding Node open on its own, but an
492
+ // abandoned connection still pins the host's server handle.
493
+ let caps;
494
+ try {
495
+ if (resolvedToken)
496
+ await session.request({ op: 'auth', token: resolvedToken });
497
+ // Negotiate up front (one loopback round-trip): the client and the host's C++
498
+ // module version independently, so this is what turns a stale module pin into
499
+ // a named mismatch instead of a late `unknown op` or an unchecked reply.
500
+ caps = await handshake(session);
501
+ requireOp(caps, 'eval', 'connect()'); // the helpers below, and every locator, ride on it
502
+ const injected = await session.request({ op: 'eval', code: PAGE_HELPERS }); // inject helpers once
503
+ // An unchecked injection hands back a Page whose every locator then fails
504
+ // cryptically; a failed auth also surfaces here rather than silently.
505
+ if (!injected.ok)
506
+ throw new Error(`bridge rejected the page helpers: ${injected.error || 'unknown error'}`);
507
+ }
508
+ catch (e) {
509
+ session.close();
510
+ throw e;
511
+ }
512
+ const page = new Page(session, { defaultTimeout: timeout, interval, log: logger, backendTimeoutMs, caps });
479
513
  page.logFile = file; // where actions are being written (null if a custom log fn was passed)
480
514
  return page;
481
515
  }
@@ -485,14 +519,18 @@ export class Page {
485
519
  interval;
486
520
  backendTimeoutMs;
487
521
  log;
522
+ /** The `hello` handshake taken at connect(), or null when the host could not
523
+ answer it. Immutable for the life of the connection (one host process). */
524
+ caps;
488
525
  logFile = null;
489
526
  // opts.log?: (msg) => void — when given, every action (click/fill/drag/backend/
490
527
  // fire) emits a concise progress line as it runs. Off by default (no-op).
491
- constructor(session, { defaultTimeout, interval, log, backendTimeoutMs = 10000 }) {
528
+ constructor(session, { defaultTimeout, interval, log, backendTimeoutMs = 10000, caps = null }) {
492
529
  this.session = session;
493
530
  this.defaultTimeout = defaultTimeout;
494
531
  this.interval = interval;
495
532
  this.backendTimeoutMs = backendTimeoutMs;
533
+ this.caps = caps;
496
534
  this.log = typeof log === 'function' ? log : () => { };
497
535
  }
498
536
  locator(selector) { return new Locator(this, selector); }
@@ -617,7 +655,10 @@ export class Page {
617
655
  events flow through the same on()/waitForEvent listeners (each carries a
618
656
  monotonic `seq` for dedup). Resolves with the number of events replayed. */
619
657
  async replayEvents({ since = 0 } = {}) {
658
+ requireOp(this.caps, 'sink_replay', 'page.replayEvents()');
620
659
  const r = await this.session.request({ op: 'sink_replay', since });
660
+ if (!r.ok)
661
+ throw new Error(r.error || 'sink_replay failed');
621
662
  return r.count;
622
663
  }
623
664
  /** Poll a JS boolean expression in the page until it is truthy (or time out).
@@ -706,14 +747,17 @@ export class Page {
706
747
  })()`));
707
748
  }
708
749
  /** Capabilities handshake: { protocolVersion, platform, ops, screenshotAvailable,
709
- authRequired }. Lets a caller branch on what the host supports (e.g. skip
710
- screenshots when screenshotAvailable is false) without probing op-by-op. */
750
+ authRequired, moduleVersion? }. Lets a caller branch on what the host supports
751
+ (e.g. skip screenshots when screenshotAvailable is false) without probing
752
+ op-by-op. Returns the handshake connect() already took — it cannot change
753
+ within a connection — and only asks again if that one did not land. */
711
754
  async capabilities() {
712
- const r = await this.session.request({ op: 'hello' });
713
- return {
714
- protocolVersion: r.protocolVersion, platform: r.platform, ops: r.ops,
715
- screenshotAvailable: r.screenshotAvailable, authRequired: r.authRequired,
716
- };
755
+ if (this.caps)
756
+ return this.caps;
757
+ const caps = await handshake(this.session);
758
+ if (!caps)
759
+ throw new Error('bridge did not answer the hello handshake');
760
+ return caps;
717
761
  }
718
762
  /** Toggle WebKit's compositing debug overlays (layer borders + repaint
719
763
  counters) on the host's WKWebView via the bridge `layerdebug` op. The
@@ -722,6 +766,7 @@ export class Page {
722
766
  macOS-only; throws where the backend has no such SPI. Remember to turn it
723
767
  OFF before any pixel-comparison capture: the overlays are pixels too. */
724
768
  async layerDebug(enabled = true) {
769
+ requireOp(this.caps, 'layerdebug', 'page.layerDebug()');
725
770
  this.log(`layerdebug ${enabled ? 'on' : 'off'}`);
726
771
  const r = await this.session.request({ op: 'layerdebug', enabled }, { timeoutMs: 10000 });
727
772
  if (!r.ok)
@@ -733,6 +778,7 @@ export class Page {
733
778
  to census compositing layers (count, geometry) from a script instead of
734
779
  reading overlay pixels off a screenshot. macOS-only; throws elsewhere. */
735
780
  async layerTree() {
781
+ requireOp(this.caps, 'layertree', 'page.layerTree()');
736
782
  this.log('layertree');
737
783
  const r = await this.session.request({ op: 'layertree' }, { timeoutMs: 10000 });
738
784
  if (!r.ok)
@@ -743,6 +789,7 @@ export class Page {
743
789
  Writes a PNG host-side and returns its path. Pass { path } to choose where, and
744
790
  { clip: {x,y,w,h} } (CSS px) to crop to a UI region for a much smaller PNG. */
745
791
  async screenshot({ path, clip } = {}) {
792
+ requireOp(this.caps, 'shot', 'page.screenshot()');
746
793
  this.log(`screenshot${clip ? ' (region)' : ''}${path ? ' ' + path : ''}`);
747
794
  const r = await this.session.request({ op: 'shot', ...(path ? { path } : {}), ...(clip ? { rect: clip } : {}) }, { timeoutMs: 30000 });
748
795
  if (!r.ok)
@@ -7,6 +7,49 @@ export interface Discovery {
7
7
  /** The port the host tries first (it scans upward on collision); clients fall
8
8
  * back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
9
9
  export declare const DEFAULT_PORT = 8930;
10
+ /** The protocol major these clients speak.
11
+ *
12
+ * The npm client and the C++ module version independently: the plugin pins a
13
+ * module tag in its build, the test host installs a client from npm, and the
14
+ * two drift. Equal versions are NOT the goal — the protocol is additive, so an
15
+ * older client against a newer module is fine by design. What matters is
16
+ * capability negotiation, and the `hello` reply carries both halves of it:
17
+ * - `ops` — the fine-grained capability list (grows additively);
18
+ * - `protocolVersion` — the coarse tripwire, moved ONLY by a breaking change.
19
+ * So a host advertising a HIGHER protocolVersion is one this client predates. */
20
+ export declare const CLIENT_PROTOCOL_VERSION = 1;
21
+ /** The `hello` handshake. `moduleVersion` is absent on hosts built against a
22
+ * module older than the release that started reporting it. */
23
+ export interface BridgeCapabilities {
24
+ protocolVersion: number;
25
+ platform: string;
26
+ ops: string[];
27
+ screenshotAvailable: boolean;
28
+ authRequired: boolean;
29
+ moduleVersion?: string;
30
+ }
31
+ /** This npm client's own version, read from the package manifest so it cannot
32
+ * drift from what was published. Falls back to 'unknown' — a diagnostic string
33
+ * must never be the thing that throws. */
34
+ export declare function clientVersion(): string;
35
+ /** Normalize a `hello` reply into capabilities, or null when the reply is not a
36
+ * usable handshake — i.e. a host too old to answer `hello` at all, which must
37
+ * keep working with every guard standing down.
38
+ *
39
+ * Deliberately total: it never throws and never inspects the transport. A
40
+ * TRANSPORT failure is not a legacy host, so callers must let that propagate
41
+ * rather than fold it into null — silently disabling the guards for the rest of
42
+ * a connection is exactly the failure this negotiation exists to prevent.
43
+ * Owning the parse here keeps the two clients from drifting apart. */
44
+ export declare function parseHello(reply: Record<string, unknown> | null | undefined): BridgeCapabilities | null;
45
+ /** Throw when the host speaks a protocol major this client cannot. Called once
46
+ * per connection, right after the handshake. */
47
+ export declare function assertProtocolSupported(caps: BridgeCapabilities): void;
48
+ /** Throw an actionable error when the host bridge lacks the op an API needs.
49
+ * Without this the host replies `unknown op: <x>`, which callers either surface
50
+ * raw or (for ops whose reply is not checked) swallow into silently wrong
51
+ * behaviour — neither of which points at the real cause: a stale module pin. */
52
+ export declare function requireOp(caps: BridgeCapabilities | null, op: string, api: string): void;
10
53
  /** Locate a running bridge's {port, token}.
11
54
  * The host writes them on start so clients never guess: each instance registers
12
55
  * <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
package/tools/shared.mjs CHANGED
@@ -12,6 +12,77 @@ import { StringDecoder } from 'node:string_decoder';
12
12
  /** The port the host tries first (it scans upward on collision); clients fall
13
13
  * back to it when no discovery file exists. Mirrors WebAgentBridge::start(). */
14
14
  export const DEFAULT_PORT = 8930;
15
+ /** The protocol major these clients speak.
16
+ *
17
+ * The npm client and the C++ module version independently: the plugin pins a
18
+ * module tag in its build, the test host installs a client from npm, and the
19
+ * two drift. Equal versions are NOT the goal — the protocol is additive, so an
20
+ * older client against a newer module is fine by design. What matters is
21
+ * capability negotiation, and the `hello` reply carries both halves of it:
22
+ * - `ops` — the fine-grained capability list (grows additively);
23
+ * - `protocolVersion` — the coarse tripwire, moved ONLY by a breaking change.
24
+ * So a host advertising a HIGHER protocolVersion is one this client predates. */
25
+ export const CLIENT_PROTOCOL_VERSION = 1;
26
+ /** This npm client's own version, read from the package manifest so it cannot
27
+ * drift from what was published. Falls back to 'unknown' — a diagnostic string
28
+ * must never be the thing that throws. */
29
+ export function clientVersion() {
30
+ try {
31
+ const manifest = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
32
+ return manifest.version ?? 'unknown';
33
+ }
34
+ catch {
35
+ return 'unknown';
36
+ }
37
+ }
38
+ /** Normalize a `hello` reply into capabilities, or null when the reply is not a
39
+ * usable handshake — i.e. a host too old to answer `hello` at all, which must
40
+ * keep working with every guard standing down.
41
+ *
42
+ * Deliberately total: it never throws and never inspects the transport. A
43
+ * TRANSPORT failure is not a legacy host, so callers must let that propagate
44
+ * rather than fold it into null — silently disabling the guards for the rest of
45
+ * a connection is exactly the failure this negotiation exists to prevent.
46
+ * Owning the parse here keeps the two clients from drifting apart. */
47
+ export function parseHello(reply) {
48
+ if (!reply || reply.ok !== true || !Array.isArray(reply.ops))
49
+ return null;
50
+ return {
51
+ protocolVersion: Number(reply.protocolVersion),
52
+ platform: String(reply.platform),
53
+ ops: reply.ops,
54
+ screenshotAvailable: reply.screenshotAvailable === true,
55
+ authRequired: reply.authRequired === true,
56
+ ...(typeof reply.moduleVersion === 'string' ? { moduleVersion: reply.moduleVersion } : {}),
57
+ };
58
+ }
59
+ /** Identify both halves of the pairing for an error message. */
60
+ function describePairing(caps) {
61
+ const host = caps.moduleVersion ?? 'version not reported (module predates moduleVersion)';
62
+ return `host module ${host}, protocol ${caps.protocolVersion}, ${caps.platform}; `
63
+ + `npm client ${clientVersion()}, protocol ${CLIENT_PROTOCOL_VERSION}`;
64
+ }
65
+ /** Throw when the host speaks a protocol major this client cannot. Called once
66
+ * per connection, right after the handshake. */
67
+ export function assertProtocolSupported(caps) {
68
+ if (!(caps.protocolVersion > CLIENT_PROTOCOL_VERSION))
69
+ return;
70
+ throw new Error(`bridge protocol ${caps.protocolVersion} is newer than this client understands `
71
+ + `(${CLIENT_PROTOCOL_VERSION}). The protocol major only moves on a BREAKING change, so this `
72
+ + `npm client is too old for the host's module — upgrade juce-webview-agent-bridge.\n ${describePairing(caps)}`);
73
+ }
74
+ /** Throw an actionable error when the host bridge lacks the op an API needs.
75
+ * Without this the host replies `unknown op: <x>`, which callers either surface
76
+ * raw or (for ops whose reply is not checked) swallow into silently wrong
77
+ * behaviour — neither of which points at the real cause: a stale module pin. */
78
+ export function requireOp(caps, op, api) {
79
+ if (caps === null || caps.ops.includes(op))
80
+ return; // null = handshake unavailable; stay out of the way
81
+ throw new Error(`${api} needs the "${op}" op, which the host's juce_webview_agent_bridge module does not provide. `
82
+ + `The plugin was built against an older module than this client expects — update the module it `
83
+ + `builds against (FetchContent pin, git submodule, or vendored copy) and rebuild the plugin.`
84
+ + `\n ${describePairing(caps)}\n host ops: ${caps.ops.join(', ')}`);
85
+ }
15
86
  /** Locate a running bridge's {port, token}.
16
87
  * The host writes them on start so clients never guess: each instance registers
17
88
  * <home>/.web_agent_bridge.d/<port>.json (so several hosts — e.g. multiple
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import net from 'node:net';
27
27
  import path from 'node:path';
28
- import { DEFAULT_PORT, loadDiscovery, onJsonLines } from './shared.mjs';
28
+ import { DEFAULT_PORT, assertProtocolSupported, loadDiscovery, onJsonLines, parseHello, requireOp } from './shared.mjs';
29
29
  const argv = process.argv.slice(2);
30
30
  const opt = (name, def) => {
31
31
  const i = argv.indexOf(name);
@@ -104,7 +104,31 @@ const fillSnippet = (sel, val) => `(() => {
104
104
  el.dispatchEvent(new Event('change', { bubbles: true }));
105
105
  return 'ok';
106
106
  })()`;
107
+ // Commands that must survive a version mismatch instead of being blocked by it:
108
+ // `ping` answers "is anything alive", and `hello` is the very tool you reach for
109
+ // to SEE a mismatch. Refusing them on a protocol bump would remove the two
110
+ // diagnostics you need most at exactly the moment you need them.
111
+ const DIAGNOSTIC_CMDS = new Set(['ping', 'hello']);
112
+ /** Negotiate once per CLI run: refuse a host whose protocol major is newer than
113
+ this client, and expose its op set to the per-command guards. Null means the
114
+ host is too old to answer `hello`, which must keep working untouched. */
115
+ let hostCaps = null;
116
+ async function negotiate() {
117
+ hostCaps = parseHello(await request({ op: 'hello' }));
118
+ if (hostCaps)
119
+ assertProtocolSupported(hostCaps);
120
+ }
121
+ /** Gate a command on an op the host may not have: without this an older module
122
+ answers `unknown op: <x>`, which reads as a client bug rather than the stale
123
+ module pin it actually is. */
124
+ function requireHostOp(op, api) {
125
+ requireOp(hostCaps, op, api);
126
+ }
107
127
  async function main() {
128
+ // One handshake per run, before any command runs: a host advertising a newer
129
+ // protocol major is refused here rather than half-served command by command.
130
+ if (cmd && !DIAGNOSTIC_CMDS.has(cmd))
131
+ await negotiate();
108
132
  switch (cmd) {
109
133
  case 'ping': {
110
134
  const r = await request({ op: 'ping' });
@@ -112,6 +136,7 @@ async function main() {
112
136
  break;
113
137
  }
114
138
  case 'layerdebug': {
139
+ requireHostOp('layerdebug', 'the `layerdebug` command');
115
140
  const enabled = rest[0] !== 'off';
116
141
  const r = await request({ op: 'layerdebug', enabled });
117
142
  console.log(r.ok
@@ -120,6 +145,7 @@ async function main() {
120
145
  break;
121
146
  }
122
147
  case 'layertree': {
148
+ requireHostOp('layertree', 'the `layertree` command');
123
149
  const r = await request({ op: 'layertree' });
124
150
  console.log(r.ok ? r.text : `failed: ${r.error || 'unavailable'}`);
125
151
  break;
@@ -127,7 +153,8 @@ async function main() {
127
153
  case 'hello': {
128
154
  const r = await request({ op: 'hello' });
129
155
  console.log(fmt({
130
- protocolVersion: r.protocolVersion, platform: r.platform,
156
+ protocolVersion: r.protocolVersion, moduleVersion: r.moduleVersion ?? '(not reported)',
157
+ platform: r.platform,
131
158
  screenshotAvailable: r.screenshotAvailable, authRequired: r.authRequired, ops: r.ops,
132
159
  }));
133
160
  break;
@@ -181,6 +208,7 @@ async function main() {
181
208
  // with a different CWD (its .app bundle), so a relative path would both
182
209
  // trip JUCE's File-ctor assertion (juce_File.cpp:219 wants absolute) and
183
210
  // write the PNG next to the bundle instead of where the caller expects.
211
+ requireHostOp('shot', 'the `shot` command');
184
212
  const out = rest[0] ? path.resolve(rest[0]) : undefined;
185
213
  const sel = rest[1];
186
214
  let rect;