@reticlehq/browser 2.3.0 → 2.5.0

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 (79) hide show
  1. package/dist/actions/actions-dom.js +10 -10
  2. package/dist/actions/actions.d.ts +13 -0
  3. package/dist/actions/actions.js +45 -15
  4. package/dist/actions/scroll.js +1 -1
  5. package/dist/commands/commands.js +39 -22
  6. package/dist/connect-options.d.ts +103 -0
  7. package/dist/connect-options.js +1 -0
  8. package/dist/dom/a11y.js +20 -20
  9. package/dist/dom/auto-anchor.js +1 -1
  10. package/dist/dom/blind-spots.js +1 -1
  11. package/dist/dom/chart.js +5 -5
  12. package/dist/dom/desktop-capture.d.ts +2 -1
  13. package/dist/dom/desktop-capture.js +4 -4
  14. package/dist/dom/occlusion.js +2 -2
  15. package/dist/dom/query.js +28 -18
  16. package/dist/dom/realm.js +13 -13
  17. package/dist/dom/refs.d.ts +14 -0
  18. package/dist/dom/refs.js +20 -1
  19. package/dist/dom/shadow-registry.js +2 -2
  20. package/dist/dom/snapshot.js +7 -7
  21. package/dist/dom/source.js +3 -3
  22. package/dist/dom/theme.js +6 -6
  23. package/dist/dom/virtualized.js +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +2 -2
  26. package/dist/observers/blind-spots.js +2 -2
  27. package/dist/observers/console.js +3 -3
  28. package/dist/observers/dom.js +5 -5
  29. package/dist/observers/download.d.ts +2 -1
  30. package/dist/observers/download.js +2 -2
  31. package/dist/observers/focus.js +1 -1
  32. package/dist/observers/frames.js +1 -1
  33. package/dist/observers/health.js +70 -2
  34. package/dist/observers/install-all.d.ts +2 -1
  35. package/dist/observers/ipc.d.ts +2 -7
  36. package/dist/observers/ipc.js +7 -7
  37. package/dist/observers/network-body.js +2 -2
  38. package/dist/observers/network-redact.js +5 -5
  39. package/dist/observers/network-stream.js +2 -2
  40. package/dist/observers/network.d.ts +3 -3
  41. package/dist/observers/network.js +9 -9
  42. package/dist/observers/perf.js +1 -1
  43. package/dist/observers/scroll.js +1 -1
  44. package/dist/observers/state.js +1 -1
  45. package/dist/observers/storage.js +7 -7
  46. package/dist/presenter/presenter-brand.d.ts +19 -0
  47. package/dist/presenter/presenter-brand.js +21 -0
  48. package/dist/presenter/presenter-config.d.ts +17 -0
  49. package/dist/presenter/presenter-config.js +21 -0
  50. package/dist/presenter/presenter-controls.d.ts +1 -11
  51. package/dist/presenter/presenter-controls.js +6 -7
  52. package/dist/presenter/presenter-log.js +2 -2
  53. package/dist/presenter/presenter-run-state.js +1 -1
  54. package/dist/presenter/presenter-styles.d.ts +1 -1
  55. package/dist/presenter/presenter-styles.js +20 -9
  56. package/dist/presenter/presenter-tally.js +3 -3
  57. package/dist/presenter/presenter-test-helpers.d.ts +2 -1
  58. package/dist/presenter/presenter-test-helpers.js +2 -2
  59. package/dist/presenter/presenter-verbs.js +16 -11
  60. package/dist/presenter/presenter.js +13 -10
  61. package/dist/recorder/recorder.js +10 -9
  62. package/dist/registry/capabilities.d.ts +2 -0
  63. package/dist/registry/capabilities.js +16 -0
  64. package/dist/registry/store-adapters.d.ts +127 -1
  65. package/dist/registry/store-adapters.js +178 -0
  66. package/dist/registry/stores.js +3 -3
  67. package/dist/reticle-presenter-helpers.js +3 -3
  68. package/dist/reticle.d.ts +2 -57
  69. package/dist/reticle.js +57 -14
  70. package/dist/review/annotator.js +7 -7
  71. package/dist/security/serialization.js +9 -9
  72. package/dist/session-continuity.d.ts +17 -0
  73. package/dist/session-continuity.js +42 -0
  74. package/dist/timers/clock.js +4 -4
  75. package/dist/timers/native-console.js +1 -1
  76. package/dist/timers/native-timers.js +4 -4
  77. package/dist/transport/transport.d.ts +10 -0
  78. package/dist/transport/transport.js +19 -5
  79. package/package.json +11 -4
@@ -1,3 +1,4 @@
1
+ import { nativeWarn } from '../timers/native-console.js';
1
2
  /**
2
3
  * Expose a TanStack Query cache as a Reticle store, keyed by query key.
3
4
  *
@@ -106,6 +107,183 @@ export function mobxStore(observable, toJS, reaction) {
106
107
  subscribe: (listener) => reaction(() => toJS(observable), () => listener()),
107
108
  };
108
109
  }
110
+ /**
111
+ * The load states a Recoil `Loadable` reports. Named because the projection below branches on them
112
+ * and a bare 'hasValue' in three places is exactly the free string the rules forbid.
113
+ */
114
+ const RecoilLoadState = {
115
+ HAS_VALUE: 'hasValue',
116
+ LOADING: 'loading',
117
+ HAS_ERROR: 'hasError',
118
+ };
119
+ function projectLoadable(loadable) {
120
+ if (loadable.state === RecoilLoadState.HAS_ERROR) {
121
+ const contents = loadable.contents;
122
+ return {
123
+ status: loadable.state,
124
+ value: null,
125
+ error: contents instanceof Error ? contents.message : String(contents),
126
+ };
127
+ }
128
+ // A LOADING loadable's `contents` is the pending promise. Sending it would serialize to `{}` and
129
+ // read as an empty value — so the value stays null and the status carries the truth.
130
+ if (loadable.state !== RecoilLoadState.HAS_VALUE) {
131
+ return { status: loadable.state, value: null, error: null };
132
+ }
133
+ return { status: loadable.state, value: loadable.contents, error: null };
134
+ }
135
+ /**
136
+ * Expose a chosen set of Recoil atoms as one Reticle store.
137
+ *
138
+ * Three things about Recoil's API shape this adapter is built around, none of them optional:
139
+ *
140
+ * - **The atom map is forced, not an ergonomic choice.** Recoil has no enumerable registry of live
141
+ * atoms, so "the whole store" is not a thing that exists. Naming the atoms is the only way to
142
+ * snapshot them, and it doubles as a declaration of what matters — the same reason `jotaiStore`
143
+ * takes one.
144
+ * - **`.state`/`.contents`, never `.getValue()`.** On a pending async selector `getValue()` THROWS
145
+ * the pending promise, and on a failed one it throws the error. Either would take down the whole
146
+ * state read over one slow atom — so a loading atom reports `status: 'loading'` with a null value
147
+ * rather than silently becoming an empty object or an exception.
148
+ * - **One subscription, not one per atom.** Recoil exposes no per-atom subscription outside React
149
+ * (`jotaiStore` gets `store.sub(atom, …)`; there is no equivalent here). The transaction stream is
150
+ * the only public change signal, so the caller passes it in — the same "the library ships free
151
+ * functions, so hand them over" shape as `valtioStore` and `mobxStore`.
152
+ *
153
+ * Wire it from a bridge component, which is where `useRecoilTransactionObserver_UNSTABLE` lives:
154
+ *
155
+ * ```tsx
156
+ * const latest = useRef(snapshot_UNSTABLE());
157
+ * const listeners = useRef(new Set<() => void>()).current;
158
+ * useRecoilTransactionObserver_UNSTABLE(({ snapshot }) => {
159
+ * latest.current = snapshot;
160
+ * for (const l of listeners) l();
161
+ * });
162
+ * registerStore('recoil', recoilStore({ cart: cartAtom }, () => latest.current, (l) => {
163
+ * listeners.add(l);
164
+ * return () => listeners.delete(l);
165
+ * }));
166
+ * ```
167
+ */
168
+ export function recoilStore(atoms, snapshot, subscribe) {
169
+ return {
170
+ // Resolved per call, not captured once: a Recoil snapshot is IMMUTABLE, so an adapter holding
171
+ // one would keep answering from the transaction it was built in and never see another write.
172
+ getState: () => {
173
+ const current = snapshot();
174
+ const out = {};
175
+ for (const [name, atom] of Object.entries(atoms)) {
176
+ out[name] = projectLoadable(current.getLoadable(atom));
177
+ }
178
+ return out;
179
+ },
180
+ subscribe,
181
+ };
182
+ }
183
+ function stopSubscription(handle) {
184
+ if ('function' === typeof handle) {
185
+ handle();
186
+ return;
187
+ }
188
+ handle.unsubscribe();
189
+ }
190
+ /**
191
+ * Expose a Svelte store as a Reticle store.
192
+ *
193
+ * Structurally unlike every other adapter in this file: they all wrap something that can be PULLED
194
+ * (`getState`, `getSnapshot`, `snapshot(proxy)`, `toJS`). A Svelte store has no pull side at all —
195
+ * `{ subscribe }` is the entire contract. What makes a pull possible anyway is the part of that
196
+ * contract which says `subscribe` must call back **immediately and synchronously** with the current
197
+ * value: subscribe, catch the value, unsubscribe, return it. That is precisely how `svelte/store`'s
198
+ * own `get()` is implemented, so it is a blessed read rather than a trick.
199
+ *
200
+ * Doing it that way is what keeps this adapter free of the `dispose` the obvious implementation
201
+ * needs. Caching the latest value would mean holding a permanent subscription, which means a
202
+ * teardown the other five adapters don't have, which means a leak the moment a caller forgets it.
203
+ * A transient subscription per read owns nothing and leaks nothing.
204
+ *
205
+ * ```ts
206
+ * registerStore('cart', svelteStore(cartStore));
207
+ * ```
208
+ */
209
+ export function svelteStore(readable, warn = nativeWarn) {
210
+ let warnedAboutLazyStore = false;
211
+ return {
212
+ getState: () => {
213
+ let value;
214
+ let called = false;
215
+ stopSubscription(readable.subscribe((next) => {
216
+ value = next;
217
+ called = true;
218
+ }));
219
+ // A store that did NOT call back synchronously (an RxJS Observable that is not a
220
+ // BehaviorSubject, a hand-rolled store that breaks the contract) leaves `value` undefined —
221
+ // indistinguishable from a store legitimately holding undefined. Reading empty when the answer
222
+ // is "unknown" is the false green this project exists to prevent, so say it once, out loud.
223
+ if (!called && !warnedAboutLazyStore) {
224
+ warnedAboutLazyStore = true;
225
+ warn('[reticle] a store passed to svelteStore did not call its subscriber synchronously, so ' +
226
+ 'its current value cannot be read. reticle_state will report undefined for it. Svelte ' +
227
+ 'stores always call back immediately; an RxJS Observable does not unless it is a ' +
228
+ 'BehaviorSubject.');
229
+ }
230
+ return value;
231
+ },
232
+ subscribe: (listener) => {
233
+ // Swallow whatever arrives DURING the subscribe call. Forwarding it would emit a STATE_CHANGE
234
+ // at REGISTRATION time for a change that never happened — a diff of nothing that shows up in
235
+ // causal summaries and that a {kind:'state'} predicate could satisfy without the app doing
236
+ // anything at all.
237
+ //
238
+ // The window is what makes this correct, rather than "swallow the first callback whenever it
239
+ // arrives". A store that does NOT call back synchronously (an RxJS Observable that is not a
240
+ // BehaviorSubject — the exact store `getState` warns about) has no registration callback to
241
+ // drop, so counting callbacks ate its first REAL change: no STATE_CHANGE, and a
242
+ // {kind:'state'} assertion with nothing to match. A silently missed state change is the false
243
+ // green this project exists to prevent, so the rule is positional, not ordinal.
244
+ let inSubscribeCall = true;
245
+ const handle = readable.subscribe(() => {
246
+ if (!inSubscribeCall)
247
+ listener();
248
+ });
249
+ inSubscribeCall = false;
250
+ return () => stopSubscription(handle);
251
+ },
252
+ };
253
+ }
254
+ /** Pinia `$subscribe` options. `sync` and `detached` are library option values, not free strings. */
255
+ const PINIA_SUBSCRIBE_OPTIONS = { detached: true, flush: 'sync' };
256
+ /**
257
+ * Expose a Pinia store as a Reticle store. `$state` / `$subscribe` are close to `{getState,
258
+ * subscribe}` but not close enough for `registerStore`'s duck-type, which is the whole reason a Vue
259
+ * app currently connects and then reports no state at all.
260
+ *
261
+ * Both `$subscribe` options are load-bearing:
262
+ *
263
+ * - **`detached`** keeps the subscription alive past the component that happened to register the
264
+ * store. Without it Pinia tears the listener down on unmount, so a store registered from inside a
265
+ * component goes permanently silent after the first route change — readable, but never again
266
+ * emitting a STATE_CHANGE, which reads exactly like an app that stopped changing.
267
+ * - **`flush: 'sync'`** puts the notification inside the action's attribution window. The default
268
+ * (`'post'`) defers it past the Vue update tick, and a state change that lands after the window
269
+ * closed is no longer linked to the click that caused it in the causal summary.
270
+ *
271
+ * `$state` carries state, not getters — a Pinia getter is derived, so asserting on the state it
272
+ * derives from is the stronger assertion anyway.
273
+ *
274
+ * ```ts
275
+ * registerStore('cart', piniaStore(useCartStore()));
276
+ * ```
277
+ */
278
+ export function piniaStore(store) {
279
+ return {
280
+ // `$state` is a live reactive proxy, so this reads through to the current value rather than
281
+ // snapshotting at registration. The transport serializer walks it like any object and guards
282
+ // each key, which is what keeps a throwing reactive trap from costing the whole read.
283
+ getState: () => store.$state,
284
+ subscribe: (listener) => store.$subscribe(() => listener(), { ...PINIA_SUBSCRIBE_OPTIONS }),
285
+ };
286
+ }
109
287
  /**
110
288
  * Build a store whose value is PUSHED in rather than pulled — for state that has no object to read.
111
289
  *
@@ -20,12 +20,12 @@ function isStoreLike(source) {
20
20
  // Zustand's `create` returns a CALLABLE hook that also carries getState/subscribe, so a store can be
21
21
  // typeof 'function' as well as 'object'. What distinguishes it from a plain getter is those two members
22
22
  // — never the typeof. (Checking only for 'object' left every Zustand app on pull-only reads.)
23
- if (source === null)
23
+ if (null === source)
24
24
  return false;
25
25
  if (typeof source !== 'object' && typeof source !== 'function')
26
26
  return false;
27
27
  const candidate = source;
28
- return typeof candidate.getState === 'function' && typeof candidate.subscribe === 'function';
28
+ return 'function' === typeof candidate.getState && 'function' === typeof candidate.subscribe;
29
29
  }
30
30
  /**
31
31
  * App calls this once per store. PREFER passing the store itself — `registerStore('app', useApp)` —
@@ -43,7 +43,7 @@ export function registerStore(name, source, subscribe) {
43
43
  return;
44
44
  }
45
45
  if (typeof source !== 'function') {
46
- const hasSubscribe = typeof source === 'object' && source !== null && 'subscribe' in source;
46
+ const hasSubscribe = 'object' === typeof source && source !== null && 'subscribe' in source;
47
47
  nativeWarn(`[reticle] store "${name}" is neither a getter function nor a {getState, subscribe} store` +
48
48
  (hasSubscribe
49
49
  ? ` — it has subscribe but no getState. Wrap it with an adapter or add getState.`
@@ -1,9 +1,9 @@
1
- import { ReticleCommand, PresenterMode } from '@reticlehq/core';
1
+ import { ReticleCommand, PresenterMode, QueryBy } from '@reticlehq/core';
2
2
  import { refs } from './dom/refs.js';
3
3
  import { describe } from './dom/a11y.js';
4
4
  /** Coerce an unknown arg to a string, falling back to `fallback` (default empty) when it isn't one. */
5
5
  export function str(value, fallback = '') {
6
- return typeof value === 'string' ? value : fallback;
6
+ return 'string' === typeof value ? value : fallback;
7
7
  }
8
8
  /** A short human label for a ref ("button \"Save\"") for the presenter HUD. */
9
9
  export function refLabel(refId) {
@@ -69,7 +69,7 @@ export function presentStatus(commandName, args = {}) {
69
69
  }
70
70
  /** Compact "what we're looking for" from a query's args (testid/value/name/role/text/label). */
71
71
  function queryTarget(q) {
72
- const testid = str(q['testid']) ?? (str(q['by']) === 'testid' ? str(q['value']) : undefined);
72
+ const testid = str(q['testid']) ?? (QueryBy.TESTID === str(q['by']) ? str(q['value']) : undefined);
73
73
  if (testid !== undefined)
74
74
  return `[testid=${testid}]`;
75
75
  const name = str(q['name']);
package/dist/reticle.d.ts CHANGED
@@ -1,62 +1,7 @@
1
1
  import { EventType, RETICLE_URL_PARAM, type ReticleEvent } from '@reticlehq/core';
2
2
  import { type CapabilitiesInput } from './registry/capabilities.js';
3
- export interface ReticleConnectOptions {
4
- /** WS endpoint of the local bridge. Defaults to ws://localhost:<port><path>. */
5
- url?: string;
6
- /** Human-friendly session label so the agent can target the right tab. */
7
- session?: string;
8
- /**
9
- * Stable project identity, normally stamped by the build plugin (e.g. "acme-web-9f3c1d"). Lets the
10
- * agent scope to the right app even when its dev server boots on an unexpected port. Optional.
11
- */
12
- projectId?: string;
13
- /** Browser/bridge pairing token. Required when either endpoint is non-localhost. */
14
- token?: string;
15
- /** Explicitly allow Reticle on a non-localhost page or bridge. Requires token. */
16
- allowNonLocalhost?: boolean;
17
- /**
18
- * Escape hatch for the production backstop. Reticle is dev-only and refuses to connect when the
19
- * build reports NODE_ENV=production (an SSR healthcheck or a prod bundle opened locally would
20
- * otherwise activate). The real fix is to gate the import behind `import.meta.env.DEV` so it's
21
- * tree-shaken out; this flag only exists for the rare intentional prod diagnostic.
22
- */
23
- allowInProduction?: boolean;
24
- /** Show a small in-page status chip (connection + event count). */
25
- overlay?: boolean;
26
- /**
27
- * Capture request/response bodies on net.request events (dev-only; text-like content only,
28
- * sensitive keys redacted, per-body capped). Off by default — bodies cost tokens and can carry PII.
29
- */
30
- captureNetworkBodies?: boolean;
31
- /** Presenter mode: glow border, animated cursor, click/hover effects, narration HUD. */
32
- present?: boolean;
33
- /** Per-action pacing (ms) in presenter mode so a human can follow. Default 450. */
34
- pace?: number;
35
- /** Min ms each narration line stays visible before the next replaces it (presenter). Default 3000. */
36
- narrationDwellMs?: number;
37
- /**
38
- * Border behavior in presenter mode: 'session' (default) persists the border for the whole
39
- * session; 'busy' restores the fade-after-idle behavior.
40
- */
41
- border?: 'session' | 'busy';
42
- /** Max accumulated activity-log rows before the oldest are pruned (presenter). Default 50. */
43
- logMax?: number;
44
- /**
45
- * Mount the floating human-recorder toolbar (Record/Stop/Annotate).
46
- * Default off — purely additive, dev-only.
47
- */
48
- recorder?: boolean;
49
- /**
50
- * Mount the "Flag a bug" annotator: the human clicks an element that looks wrong, types what's
51
- * wrong, and Reticle emits a HUMAN_MARK the agent drains via reticle_review. Defaults to ON with the
52
- * presenter (it's the human's side of the look→act→assert loop); pass `annotate: false` to suppress.
53
- */
54
- annotate?: boolean;
55
- /** Live-control: overridable ended-border fade delay (native timer). Default 4000. */
56
- endedFadeMs?: number;
57
- /** Session auto-end after this much agent idle (presenter). Default 5min; agent-tunable via reticle_session. */
58
- idleEndMs?: number;
59
- }
3
+ import type { ReticleConnectOptions } from './connect-options.js';
4
+ export type { ReticleConnectOptions };
60
5
  /**
61
6
  * Runtime backstop for the dev-only SDK: block connecting when the build reports production, unless
62
7
  * explicitly overridden. Pure so it's testable; connect reads NODE_ENV safely (process may be absent
package/dist/reticle.js CHANGED
@@ -1,8 +1,9 @@
1
- import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, isLoopbackHostname, isLocalPage, } from '@reticlehq/core';
1
+ import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
2
+ import { rememberSessionLabel } from './session-continuity.js';
2
3
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
3
4
  import { Transport } from './transport/transport.js';
4
5
  import { adapterNames } from './registry/adapters.js';
5
- import { registerCapabilities, hasCapabilities, } from './registry/capabilities.js';
6
+ import { registerCapabilities, setCapabilitiesListener, hasCapabilities, } from './registry/capabilities.js';
6
7
  import { installAllObservers } from './observers/install-all.js';
7
8
  import { installOverlay } from './presenter/overlay.js';
8
9
  import { Presenter, LOG_KIND, LOG_RESULT, } from './presenter/presenter.js';
@@ -19,7 +20,7 @@ import { Annotator } from './review/annotator.js';
19
20
  * behind `import.meta.env.DEV` so the SDK is dead-code-eliminated from prod bundles entirely.
20
21
  */
21
22
  export function shouldBlockProduction(nodeEnv, allowInProduction) {
22
- return nodeEnv === 'production' && !allowInProduction;
23
+ return 'production' === nodeEnv && !allowInProduction;
23
24
  }
24
25
  export function connectionPolicy(pageHostname, bridgeUrl, allowNonLocalhost, token,
25
26
  /** The page's protocol. Desktop webviews (file:, app:, tauri:) are local despite a non-loopback host. */
@@ -53,7 +54,7 @@ pageProtocol = 'http:') {
53
54
  reason: 'Reticle is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
54
55
  };
55
56
  }
56
- if (token === undefined || token.length === 0) {
57
+ if (token === undefined || 0 === token.length) {
57
58
  return { allowed: false, reason: 'a pairing token is required outside localhost' };
58
59
  }
59
60
  return { allowed: true };
@@ -145,19 +146,22 @@ export class Reticle {
145
146
  #annotator;
146
147
  #eventCount = 0;
147
148
  #token;
149
+ #sdkVersion;
148
150
  #projectId;
151
+ /** App-declared extra redaction keys, announced in hello so the driven path honours them too. */
152
+ #redactKeys = [];
149
153
  /** Act-row log handle for the in-flight act/act_sequence, so its outcome stamps the right row. */
150
154
  #actHandle;
151
155
  connect(options = {}) {
152
156
  if (this.#connected)
153
157
  return;
154
- if (typeof window === 'undefined' || typeof document === 'undefined')
158
+ if ('undefined' === typeof window || 'undefined' === typeof document)
155
159
  return;
156
160
  // Dev-only backstop: refuse to activate in a production build (SSR healthcheck, prod bundle opened
157
161
  // on localhost). `process` may not exist in a raw browser, so read NODE_ENV off globalThis.
158
162
  const proc = globalThis.process;
159
163
  const nodeEnv = proc?.env?.NODE_ENV;
160
- if (shouldBlockProduction(nodeEnv, options.allowInProduction === true)) {
164
+ if (shouldBlockProduction(nodeEnv, true === options.allowInProduction)) {
161
165
  globalThis.console.warn('[Reticle] disabled in production (NODE_ENV=production). Gate the import behind ' +
162
166
  'import.meta.env.DEV, or pass allowInProduction:true to override.');
163
167
  return;
@@ -167,20 +171,40 @@ export class Reticle {
167
171
  // reload permanently pollutes the URL the app and the agent see.
168
172
  stripReloadCacheBustParam();
169
173
  const url = options.url ?? bridgeWsUrl(RETICLE_DEFAULT_PORT);
170
- const policy = connectionPolicy(window.location.hostname, url, options.allowNonLocalhost === true, options.token, window.location.protocol);
174
+ const policy = connectionPolicy(window.location.hostname, url, true === options.allowNonLocalhost, options.token, window.location.protocol);
171
175
  if (!policy.allowed) {
172
176
  globalThis.console.warn(`[Reticle] ${policy.reason ?? 'connection blocked'}`);
173
177
  return;
174
178
  }
179
+ // Publish the project root for the adapters' source relativisation. Done HERE, in library code,
180
+ // so the app-side connect stays plain JavaScript — see ReticleConnectOptions.root.
181
+ if (options.root !== undefined && options.root.length > 0) {
182
+ globalThis[RETICLE_ROOT_GLOBAL] = options.root;
183
+ }
184
+ // The build plugin defines this; a hand-wired connect can pass it explicitly. Either way an
185
+ // absent value means UNKNOWN, so the bridge never reports an unknown pair as in sync.
186
+ const declaredVersion = options.sdkVersion ?? globalThis[RETICLE_SDK_VERSION_GLOBAL];
187
+ this.#sdkVersion =
188
+ 'string' === typeof declaredVersion && declaredVersion.length > 0
189
+ ? declaredVersion
190
+ : undefined;
175
191
  // A pooled/headless launcher can stamp identity via namespaced URL params; explicit (non-auto)
176
192
  // options win, but the `auto` sentinel defers to the URL param so leases correlate.
177
193
  const identity = resolveConnectIdentity(options, window.location.search);
178
- this.#session = resolveSessionLabel(identity.session, () => typeof globalThis.crypto?.randomUUID === 'function'
194
+ // Remembered per TAB, so a reload rejoins the same session instead of appearing as a new one and
195
+ // stranding the agent's handle — see session-continuity. An explicit id (connect option, or the
196
+ // lease's URL param) still wins.
197
+ const explicitSession = resolveSessionLabel(identity.session, () => '');
198
+ this.#session = rememberSessionLabel(explicitSession.length > 0 ? explicitSession : undefined, 'undefined' === typeof globalThis.sessionStorage ? undefined : globalThis.sessionStorage, () => 'function' === typeof globalThis.crypto?.randomUUID
179
199
  ? `s${globalThis.crypto.randomUUID()}`
180
200
  : `s${Date.now().toString(36)}`);
181
201
  this.#token =
182
202
  options.token !== undefined && options.token.length > 0 ? options.token : undefined;
183
203
  this.#projectId = identity.projectId;
204
+ // Install the redaction rule BEFORE anything can emit. Every observer, the serializer and the
205
+ // snapshot path read it ambiently, so a policy set even one line after installAllObservers would
206
+ // leave the first events of the session redacted by a different rule than the rest.
207
+ this.#applyRedaction(options.redact);
184
208
  this.#start = performance.now();
185
209
  this.#registry = createCommandRegistry();
186
210
  this.#transport = new Transport({
@@ -200,7 +224,7 @@ export class Reticle {
200
224
  // lodash debounce/throttle in the app (now - lastCall stays 0), so search boxes, autosave
201
225
  // and resize handlers stop firing until a reload — with nothing on screen explaining why.
202
226
  resetClock();
203
- if (this.#presenter?.sessionActive === true) {
227
+ if (true === this.#presenter?.sessionActive) {
204
228
  this.#presenter.setState(SessionState.ENDED, BRIDGE_LOST_SUMMARY);
205
229
  }
206
230
  },
@@ -213,11 +237,14 @@ export class Reticle {
213
237
  `or reticle.connect({ url })). Still retrying…`);
214
238
  },
215
239
  });
240
+ // Capabilities registered AFTER connect (which is when they are registered, by design) must
241
+ // reach the bridge, or an app that declared its whole testable surface reads as having none.
242
+ setCapabilitiesListener(() => this.#transport?.reannounce());
216
243
  const emit = this.#emit;
217
244
  this.#teardowns = installAllObservers(emit, {
218
- captureBodies: options.captureNetworkBodies === true,
245
+ captureBodies: true === options.captureNetworkBodies,
219
246
  });
220
- if (options.overlay === true) {
247
+ if (true === options.overlay) {
221
248
  this.#overlay = installOverlay();
222
249
  this.#overlay.update({ connected: true, events: 0 });
223
250
  }
@@ -247,7 +274,7 @@ export class Reticle {
247
274
  // so the presenter is visible as soon as the agent is reachable — not just on first command.
248
275
  this.#presenter.mount();
249
276
  }
250
- if (options.recorder === true) {
277
+ if (true === options.recorder) {
251
278
  this.#recorder = installRecorder({ emit, now: () => Date.now() });
252
279
  this.#recorder.mount();
253
280
  }
@@ -288,7 +315,7 @@ export class Reticle {
288
315
  }
289
316
  /** Advertise the app's testable surface so the agent learns it without reading source. */
290
317
  describe(input) {
291
- registerCapabilities(input);
318
+ registerCapabilities(input); // the registry notifies the transport — see setCapabilitiesListener
292
319
  }
293
320
  /** Live-control: end the session programmatically from the host app (drives the panel to ended). */
294
321
  endSession() {
@@ -340,6 +367,16 @@ export class Reticle {
340
367
  /* observation is best-effort; the host app's control flow is not */
341
368
  }
342
369
  };
370
+ /**
371
+ * Resolve the app's redaction config into the ambient policy, and remember the part of it that
372
+ * travels. A config of `undefined` is not "no policy" — it is the DEFAULT policy, installed
373
+ * explicitly so a second connect() in the same page (HMR, a re-mount) cannot inherit a rule the
374
+ * previous one set.
375
+ */
376
+ #applyRedaction(config) {
377
+ setActiveRedactionPolicy(buildRedactionPolicy(config, nativeWarn));
378
+ this.#redactKeys = wireRedactionKeys(config);
379
+ }
343
380
  #hello() {
344
381
  return {
345
382
  kind: MessageKind.HELLO,
@@ -351,6 +388,12 @@ export class Reticle {
351
388
  adapters: adapterNames(),
352
389
  ...(this.#token === undefined ? {} : { token: this.#token }),
353
390
  hasCapabilities: hasCapabilities(),
391
+ // Absent when no build plugin supplied one — "unknown", never "matching".
392
+ ...(this.#sdkVersion === undefined ? {} : { sdkVersion: this.#sdkVersion }),
393
+ // Always present: derived from THIS build's core, so it needs no build plugin to supply it.
394
+ // It is the half of the skew check that works on a hand-wired connect.
395
+ contract: CONTRACT_FINGERPRINT,
396
+ ...(0 === this.#redactKeys.length ? {} : { redactKeys: this.#redactKeys }),
354
397
  };
355
398
  }
356
399
  async #handleCommand(command) {
@@ -363,7 +406,7 @@ export class Reticle {
363
406
  // SESSION_CONFIG: the agent tunes the session for the app (currently the idle-end window).
364
407
  if (command.name === ReticleCommand.SESSION_CONFIG) {
365
408
  const idleEndMs = command.args['idleEndMs'];
366
- if (typeof idleEndMs === 'number')
409
+ if ('number' === typeof idleEndMs)
367
410
  this.#presenter?.setIdleEndMs(idleEndMs);
368
411
  return { ok: true, result: { applied: this.#presenter !== undefined, idleEndMs } };
369
412
  }
@@ -80,7 +80,7 @@ export class Annotator {
80
80
  return this.#markCount;
81
81
  }
82
82
  mount() {
83
- if (this.#root !== undefined || typeof document === 'undefined')
83
+ if (this.#root !== undefined || 'undefined' === typeof document)
84
84
  return;
85
85
  const style = document.createElement('style');
86
86
  style.setAttribute(MARK_ATTR, 'style');
@@ -193,7 +193,7 @@ export class Annotator {
193
193
  return;
194
194
  }
195
195
  const rect = target.getBoundingClientRect();
196
- if (rect.width === 0 && rect.height === 0) {
196
+ if (0 === rect.width && 0 === rect.height) {
197
197
  this.#hi.setAttribute('data-on', '0');
198
198
  return;
199
199
  }
@@ -239,22 +239,22 @@ export class Annotator {
239
239
  const send = pop.querySelector('button[data-send]');
240
240
  const submit = () => {
241
241
  const note = textarea?.value.trim() ?? '';
242
- if (note.length === 0)
242
+ if (0 === note.length)
243
243
  return; // never send an empty mark
244
244
  this.#sendMark(note, resolved, x, y);
245
245
  this.#closePopover();
246
246
  };
247
247
  textarea?.addEventListener('input', () => {
248
248
  if (send !== null)
249
- send.disabled = textarea.value.trim().length === 0;
249
+ send.disabled = 0 === textarea.value.trim().length;
250
250
  });
251
251
  // Keyboard ergonomics devs expect: ⌘/Ctrl+Enter sends, Esc cancels — without leaving the textarea.
252
252
  textarea?.addEventListener('keydown', (e) => {
253
- if (e.key === 'Escape') {
253
+ if ('Escape' === e.key) {
254
254
  e.preventDefault();
255
255
  this.#closePopover();
256
256
  }
257
- else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
257
+ else if ('Enter' === e.key && (e.metaKey || e.ctrlKey)) {
258
258
  e.preventDefault();
259
259
  submit();
260
260
  }
@@ -269,7 +269,7 @@ export class Annotator {
269
269
  anchor: resolved.anchor,
270
270
  strategy: resolved.strategy,
271
271
  label: resolved.label,
272
- route: typeof location === 'undefined' ? '' : location.pathname + location.search,
272
+ route: 'undefined' === typeof location ? '' : location.pathname + location.search,
273
273
  };
274
274
  if (resolved.source !== undefined)
275
275
  data['source'] = resolved.source;
@@ -31,18 +31,18 @@ function sanitize(value, state, depth, key) {
31
31
  return TRUNCATED_VALUE;
32
32
  }
33
33
  state.nodes += 1;
34
- if (value === null || typeof value === 'boolean')
34
+ if (null === value || 'boolean' === typeof value)
35
35
  return value;
36
- if (typeof value === 'string') {
37
- return boundedString(value, state, key?.toLowerCase() === 'error'
36
+ if ('string' === typeof value) {
37
+ return boundedString(value, state, 'error' === key?.toLowerCase()
38
38
  ? TRANSPORT_LIMITS.MAX_ERROR_LENGTH
39
39
  : TRANSPORT_LIMITS.MAX_STRING_LENGTH);
40
40
  }
41
- if (typeof value === 'number')
41
+ if ('number' === typeof value)
42
42
  return Number.isFinite(value) ? value : null;
43
- if (typeof value === 'bigint')
43
+ if ('bigint' === typeof value)
44
44
  return value.toString();
45
- if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') {
45
+ if ('undefined' === typeof value || 'function' === typeof value || 'symbol' === typeof value) {
46
46
  return OMIT_VALUE;
47
47
  }
48
48
  // An invalid Date (`new Date(NaN)` / `new Date(undefined)`, easy to land in app state) throws
@@ -65,7 +65,7 @@ function sanitize(value, state, depth, key) {
65
65
  state.droppedItems += value.size - n;
66
66
  break;
67
67
  }
68
- const sk = boundedString(typeof k === 'string' ? k : String(k), state, MAX_KEY_LENGTH);
68
+ const sk = boundedString('string' === typeof k ? k : String(k), state, MAX_KEY_LENGTH);
69
69
  const sv = sanitize(v, state, depth + 1, sk);
70
70
  if (sv !== OMIT_VALUE)
71
71
  out[sk] = sv;
@@ -176,7 +176,7 @@ function sanitize(value, state, depth, key) {
176
176
  const read = keys.map((key) => {
177
177
  try {
178
178
  const v = value[key];
179
- return { key, value: v, scalar: v === null || typeof v !== 'object', ok: true };
179
+ return { key, value: v, scalar: null === v || typeof v !== 'object', ok: true };
180
180
  }
181
181
  catch {
182
182
  return { key, value: undefined, scalar: true, ok: false };
@@ -224,7 +224,7 @@ export function sanitizeWithReport(value) {
224
224
  };
225
225
  const sanitized = sanitize(value, state, 0);
226
226
  const out = sanitized === OMIT_VALUE ? null : sanitized;
227
- if (state.droppedItems === 0 && state.truncatedValues === 0)
227
+ if (0 === state.droppedItems && 0 === state.truncatedValues)
228
228
  return { value: out };
229
229
  const parts = [];
230
230
  if (state.droppedItems > 0)
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Keep a tab's session id across a reload.
3
+ *
4
+ * The id was minted fresh on every `connect()`. A reload is a new document, so the page came back as
5
+ * a NEW session while the agent still held the old id — and every call after
6
+ * `reticle_navigate { reload: true }` was refused with "no browser session connected". Reported on 6
7
+ * of 6 apps; a reload is the most ordinary thing an agent does.
8
+ *
9
+ * `sessionStorage` is exactly the right scope: it survives reloads and same-tab navigations, and is
10
+ * NOT shared with another tab of the same app — so two tabs stay two sessions, which is what the
11
+ * multi-session tooling assumes.
12
+ *
13
+ * An explicit id still wins. `connect({ session })` and the `__reticle_session` URL param a lease
14
+ * stamps are callers ASSERTING an identity, not asking for one; they are also remembered, so a
15
+ * leased tab that reloads rejoins its own lease.
16
+ */
17
+ export declare function rememberSessionLabel(explicit: string | undefined, store: Storage | undefined, generate: () => string): string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Keep a tab's session id across a reload.
3
+ *
4
+ * The id was minted fresh on every `connect()`. A reload is a new document, so the page came back as
5
+ * a NEW session while the agent still held the old id — and every call after
6
+ * `reticle_navigate { reload: true }` was refused with "no browser session connected". Reported on 6
7
+ * of 6 apps; a reload is the most ordinary thing an agent does.
8
+ *
9
+ * `sessionStorage` is exactly the right scope: it survives reloads and same-tab navigations, and is
10
+ * NOT shared with another tab of the same app — so two tabs stay two sessions, which is what the
11
+ * multi-session tooling assumes.
12
+ *
13
+ * An explicit id still wins. `connect({ session })` and the `__reticle_session` URL param a lease
14
+ * stamps are callers ASSERTING an identity, not asking for one; they are also remembered, so a
15
+ * leased tab that reloads rejoins its own lease.
16
+ */
17
+ /** Where the id lives. Namespaced like the URL param it mirrors. */
18
+ const STORAGE_KEY = '__reticle_session';
19
+ export function rememberSessionLabel(explicit, store, generate) {
20
+ const read = () => {
21
+ try {
22
+ const value = store?.getItem(STORAGE_KEY);
23
+ return value !== null && value !== undefined && value.length > 0 ? value : undefined;
24
+ }
25
+ catch {
26
+ return undefined; // a sandboxed iframe throws on access; a session must still start
27
+ }
28
+ };
29
+ const write = (value) => {
30
+ try {
31
+ store?.setItem(STORAGE_KEY, value);
32
+ }
33
+ catch {
34
+ /* same: never block connect on storage */
35
+ }
36
+ };
37
+ // `explicit` may be '' — resolveSessionLabel yields that for "none given" — and `??` would take it.
38
+ const asked = explicit !== undefined && explicit.length > 0 ? explicit : undefined;
39
+ const chosen = asked ?? read() ?? generate();
40
+ write(chosen);
41
+ return chosen;
42
+ }