@reticlehq/browser 2.4.0 → 2.6.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 (75) hide show
  1. package/dist/actions/actions-dom.d.ts +8 -1
  2. package/dist/actions/actions-dom.js +26 -12
  3. package/dist/actions/actions.d.ts +46 -0
  4. package/dist/actions/actions.js +114 -26
  5. package/dist/actions/appeared-text.d.ts +27 -0
  6. package/dist/actions/appeared-text.js +92 -0
  7. package/dist/actions/scroll.js +1 -1
  8. package/dist/commands/commands.js +45 -22
  9. package/dist/connect-options.d.ts +6 -0
  10. package/dist/dom/a11y.js +20 -20
  11. package/dist/dom/auto-anchor.js +1 -1
  12. package/dist/dom/blind-spots.js +1 -1
  13. package/dist/dom/chart.js +5 -5
  14. package/dist/dom/desktop-capture.d.ts +2 -1
  15. package/dist/dom/desktop-capture.js +4 -4
  16. package/dist/dom/occlusion.js +2 -2
  17. package/dist/dom/query.js +68 -25
  18. package/dist/dom/realm.js +13 -13
  19. package/dist/dom/refs.d.ts +14 -0
  20. package/dist/dom/refs.js +20 -1
  21. package/dist/dom/shadow-registry.js +2 -2
  22. package/dist/dom/snapshot.js +32 -11
  23. package/dist/dom/source.js +3 -3
  24. package/dist/dom/theme.js +6 -6
  25. package/dist/dom/virtualized.js +1 -1
  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/store-adapters.js +1 -1
  63. package/dist/registry/stores.js +3 -3
  64. package/dist/reticle-presenter-helpers.js +3 -3
  65. package/dist/reticle.js +31 -13
  66. package/dist/review/annotator.js +7 -7
  67. package/dist/security/serialization.js +9 -9
  68. package/dist/session-continuity.d.ts +17 -0
  69. package/dist/session-continuity.js +42 -0
  70. package/dist/timers/clock.js +28 -15
  71. package/dist/timers/native-console.js +1 -1
  72. package/dist/timers/native-timers.js +4 -4
  73. package/dist/transport/transport.d.ts +23 -0
  74. package/dist/transport/transport.js +85 -25
  75. package/package.json +6 -4
@@ -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
+ }
@@ -12,7 +12,7 @@ export function isClockFrozen() {
12
12
  return installed;
13
13
  }
14
14
  export function freezeClock() {
15
- if (installed || typeof window === 'undefined')
15
+ if (installed || 'undefined' === typeof window)
16
16
  return;
17
17
  installed = true;
18
18
  virtualNow = 0;
@@ -81,7 +81,7 @@ export function advanceClock(ms) {
81
81
  * in still has ~3s to go), and the ids the app is holding keep working — see the translation note.
82
82
  */
83
83
  export function resetClock() {
84
- if (!installed || originals === null)
84
+ if (!installed || null === originals)
85
85
  return;
86
86
  const natives = originals;
87
87
  const pending = tasks;
@@ -95,7 +95,7 @@ export function resetClock() {
95
95
  tasks = [];
96
96
  installed = false;
97
97
  virtualNow = 0;
98
- if (pending.length === 0)
98
+ if (0 === pending.length)
99
99
  return;
100
100
  // Re-arm the app's pending work onto REAL timers, translating ids as we go.
101
101
  //
@@ -109,18 +109,26 @@ export function resetClock() {
109
109
  //
110
110
  // So keep a translation map and a clear-shim for exactly as long as re-armed work is outstanding.
111
111
  // Nothing is dropped, and the app's handles keep working.
112
+ // Bound to `window`, not called off `natives`. A timer native invoked with any other receiver
113
+ // throws `TypeError: Illegal invocation` in a real browser — and `natives.setTimeout(...)` passes
114
+ // `natives`. jsdom does not enforce the receiver, so every unit test here passed while the browser
115
+ // threw on the one path that matters: re-arming work the app queued during the freeze. The early
116
+ // return above means it only fires when something IS pending, which is exactly when this function
117
+ // has a job to do. Caught by `bench/harness/clock-timetravel.mjs` driving real Chromium.
118
+ const nativeSetTimeout = natives.setTimeout.bind(window);
119
+ const nativeSetInterval = natives.setInterval.bind(window);
112
120
  const reArmed = new Map();
113
121
  const done = (virtualId) => {
114
122
  reArmed.delete(virtualId);
115
- if (reArmed.size === 0)
123
+ if (0 === reArmed.size)
116
124
  restoreRawClears(natives);
117
125
  };
118
126
  for (const task of pending) {
119
127
  if (task.interval !== undefined) {
120
- reArmed.set(task.id, natives.setInterval(task.cb, task.interval));
128
+ reArmed.set(task.id, nativeSetInterval(task.cb, task.interval));
121
129
  }
122
130
  else {
123
- reArmed.set(task.id, natives.setTimeout(() => {
131
+ reArmed.set(task.id, nativeSetTimeout(() => {
124
132
  done(task.id);
125
133
  task.cb();
126
134
  }, Math.max(0, task.time - resumeFrom)));
@@ -130,15 +138,20 @@ export function resetClock() {
130
138
  }
131
139
  /** Route a clear() for a re-armed virtual id to its real native id; everything else passes through. */
132
140
  function installTranslatingClears(reArmed, natives, done) {
133
- const translate = (rawClear) => ((id) => {
134
- const nativeId = reArmed.get(id);
135
- if (nativeId === undefined) {
136
- rawClear(id);
137
- return;
138
- }
139
- rawClear(nativeId);
140
- done(id);
141
- });
141
+ // `.bind(window)` for the same reason as the re-arm above: a bare `rawClear(id)` reaches the DOM
142
+ // with the wrong receiver and throws `Illegal invocation` in a real browser.
143
+ const translate = (rawClear) => {
144
+ const clear = rawClear.bind(window);
145
+ return ((id) => {
146
+ const nativeId = reArmed.get(id);
147
+ if (nativeId === undefined) {
148
+ clear(id);
149
+ return;
150
+ }
151
+ clear(nativeId);
152
+ done(id);
153
+ });
154
+ };
142
155
  window.clearTimeout = translate(natives.clearTimeout);
143
156
  window.clearInterval = translate(natives.clearInterval);
144
157
  }
@@ -3,7 +3,7 @@
3
3
  // MUST use this instead of bare `console.warn` — otherwise they emit spurious CONSOLE_WARN
4
4
  // events to the agent, polluting the observation stream with SDK internals.
5
5
  const g = globalThis;
6
- const realWarn = typeof g.console?.warn === 'function' ? g.console.warn.bind(g.console) : null;
6
+ const realWarn = 'function' === typeof g.console?.warn ? g.console.warn.bind(g.console) : null;
7
7
  export const nativeWarn = (...args) => {
8
8
  realWarn?.(...args);
9
9
  };
@@ -5,10 +5,10 @@
5
5
  // Important: we bind the reference now. Resolving `setTimeout` at call time would pick up the
6
6
  // patched (frozen) timer once reticle_clock freezes the clock — which would hang the SDK.
7
7
  const g = globalThis;
8
- const realSetTimeout = typeof g.setTimeout === 'function' ? g.setTimeout.bind(g) : null;
9
- const realClearTimeout = typeof g.clearTimeout === 'function' ? g.clearTimeout.bind(g) : null;
10
- const realRaf = typeof g.requestAnimationFrame === 'function' ? g.requestAnimationFrame.bind(g) : null;
11
- const realPerfNow = typeof g.performance?.now === 'function' ? g.performance.now.bind(g.performance) : null;
8
+ const realSetTimeout = 'function' === typeof g.setTimeout ? g.setTimeout.bind(g) : null;
9
+ const realClearTimeout = 'function' === typeof g.clearTimeout ? g.clearTimeout.bind(g) : null;
10
+ const realRaf = 'function' === typeof g.requestAnimationFrame ? g.requestAnimationFrame.bind(g) : null;
11
+ const realPerfNow = 'function' === typeof g.performance?.now ? g.performance.now.bind(g.performance) : null;
12
12
  /**
13
13
  * Monotonic "now" bound at load. clock.ts deliberately does NOT patch performance.now, so this
14
14
  * stays live even when the app clock (reticle_clock) is frozen — the presenter state machine relies
@@ -36,7 +36,30 @@ interface TransportDeps {
36
36
  * unsubscribe; injected so a test can drive visibility without a real document.
37
37
  */
38
38
  onVisible?: (handler: () => void) => () => void;
39
+ /**
40
+ * Schedule an internal retry (default: `nativeSetTimeout`). Injected for the same reason `now` and
41
+ * `onVisible` are — the default is bound to the real timer at module load so a frozen app clock
42
+ * cannot deadlock the SDK, which also means no fake clock can observe what was scheduled.
43
+ */
44
+ schedule?: (fn: () => void, ms: number) => void;
39
45
  }
46
+ /**
47
+ * The ceiling on reconnect backoff.
48
+ *
49
+ * A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
50
+ * ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
51
+ * ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
52
+ * focus/visibility, so a human returning to the tab retries immediately rather than waiting out
53
+ * the delay; the backoff resets on that path for the same reason.
54
+ */
55
+ export declare const RECONNECT_MAX_DELAY_MS = 30000;
56
+ /**
57
+ * The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
58
+ * the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
59
+ * (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
60
+ * by a fake clock.
61
+ */
62
+ export declare function nextReconnectDelay(previous: number): number;
40
63
  /** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
41
64
  export declare const MAX_QUEUE = 500;
42
65
  /** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
@@ -1,19 +1,39 @@
1
- import { CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
1
+ import { CHURN_TYPES, CommandMessageSchema, EventType, MessageKind, SESSION_LIFECYCLE, } from '@reticlehq/core';
2
2
  import { nativeSetTimeout, nativeNow } from '../timers/native-timers.js';
3
3
  import { nativeWarn } from '../timers/native-console.js';
4
4
  import { safeStringify } from '../security/serialization.js';
5
5
  /** Default visibility source: fire `handler` whenever the document returns to the foreground. */
6
6
  function subscribeDocumentVisible(handler) {
7
- if (typeof document === 'undefined')
7
+ if ('undefined' === typeof document)
8
8
  return () => undefined;
9
9
  const listener = () => {
10
- if (document.visibilityState === 'visible')
10
+ if ('visible' === document.visibilityState)
11
11
  handler();
12
12
  };
13
13
  document.addEventListener('visibilitychange', listener);
14
14
  return () => document.removeEventListener('visibilitychange', listener);
15
15
  }
16
+ /** The first retry after a drop. Kept short: the common outage is a daemon restarting. */
16
17
  const RECONNECT_DELAY_MS = 1000;
18
+ /**
19
+ * The ceiling on reconnect backoff.
20
+ *
21
+ * A flat 1s retry with no growth and no cap meant a tab left open against a stopped daemon made
22
+ * ~600 failed WebSocket opens in ten minutes — CPU, and a devtools console full of
23
+ * ERR_CONNECTION_REFUSED. Backing off is only safe BECAUSE this transport also reconnects on
24
+ * focus/visibility, so a human returning to the tab retries immediately rather than waiting out
25
+ * the delay; the backoff resets on that path for the same reason.
26
+ */
27
+ export const RECONNECT_MAX_DELAY_MS = 30_000;
28
+ /**
29
+ * The next reconnect delay: double, then clamp. Pure, and exported so the POLICY is testable —
30
+ * the scheduling itself runs on `nativeSetTimeout`, which is bound to the real timer at module load
31
+ * (deliberately, so a frozen `reticle_clock` cannot deadlock the SDK) and therefore cannot be driven
32
+ * by a fake clock.
33
+ */
34
+ export function nextReconnectDelay(previous) {
35
+ return Math.min(previous * 2, RECONNECT_MAX_DELAY_MS);
36
+ }
17
37
  /** Offline queue cap. Exported so tests overflow the real bound instead of a mirrored copy of it. */
18
38
  export const MAX_QUEUE = 500;
19
39
  /**
@@ -29,10 +49,14 @@ const UNREACHABLE_WARN_AFTER = 3;
29
49
  /** WebSocket client to the bridge. Reconnects across reloads; buffers events while down. */
30
50
  export class Transport {
31
51
  #ws;
32
- #queue = [];
52
+ #churnQueue = [];
53
+ #signalQueue = [];
54
+ #insertOrder = 0;
33
55
  #closed = false;
34
56
  /** When the current continuous outage began (nativeNow), or undefined while connected. */
35
57
  #disconnectedSince;
58
+ /** Current reconnect delay; grows on each failure, resets on a successful open or on focus. */
59
+ #reconnectDelay = RECONNECT_DELAY_MS;
36
60
  /** Whether onConnectionLost has already fired for the current outage (fire-once). */
37
61
  #lost = false;
38
62
  /** True once the socket has opened at least once — gates the "unreachable" first-connect warning. */
@@ -60,7 +84,7 @@ export class Transport {
60
84
  this.#now = deps.now ?? nativeNow;
61
85
  }
62
86
  connect() {
63
- if (typeof WebSocket === 'undefined')
87
+ if ('undefined' === typeof WebSocket)
64
88
  return;
65
89
  this.#closed = false;
66
90
  this.#unsubscribeVisible ??= (this.#deps.onVisible ?? subscribeDocumentVisible)(() => this.#onVisible());
@@ -72,7 +96,14 @@ export class Transport {
72
96
  * exists (connected or mid-connect), so we never open a duplicate racing the scheduled retry.
73
97
  */
74
98
  #onVisible() {
75
- if (this.#closed || this.#ws !== undefined)
99
+ if (this.#closed)
100
+ return;
101
+ // A human just came back to the tab, which is the strongest signal the outage may be over — and
102
+ // it is what makes a 30s ceiling safe. Reset the backoff BEFORE the mid-connect early return: if
103
+ // a socket is already in flight and then fails, that retry should be prompt too, rather than
104
+ // inheriting a delay earned while nobody was watching.
105
+ this.#reconnectDelay = RECONNECT_DELAY_MS;
106
+ if (this.#ws !== undefined)
76
107
  return;
77
108
  this.#open();
78
109
  }
@@ -90,8 +121,7 @@ export class Transport {
90
121
  // note the outage and schedule the normal retry.
91
122
  this.#noteOutage();
92
123
  this.#noteInitialFailure();
93
- if (!this.#closed)
94
- nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
124
+ this.#scheduleReopen();
95
125
  return;
96
126
  }
97
127
  this.#ws = ws;
@@ -99,22 +129,26 @@ export class Transport {
99
129
  this.#disconnectedSince = undefined; // healthy again — reset the loss timer
100
130
  this.#lost = false;
101
131
  this.#everConnected = true; // a real connection happened ⇒ never warn "unreachable"
132
+ this.#reconnectDelay = RECONNECT_DELAY_MS; // recovered — the next drop starts prompt again
102
133
  // HELLO is SDK-owned schema data. Preserve its pairing token; the generic sanitizer
103
134
  // intentionally redacts fields named "token" from app-controlled payloads.
104
135
  const greeting = this.#deps.hello();
105
136
  this.#sessionId ??= greeting.sessionId;
106
137
  ws.send(JSON.stringify(greeting));
107
- // Declare any gap BEFORE replaying the backlog. The queue evicts its OLDEST entries, so the
108
- // hole always precedes everything that survived it.
138
+ // Declare any gap BEFORE replaying the backlog, stamped with the OLDEST event the queue
139
+ // dropped — see #sendRaw. The two queues are drained back into one stream in insertion order,
140
+ // so what the bridge receives is the order the page produced, minus the holes.
109
141
  this.#sendGapMarker(ws, greeting.sessionId);
110
- for (const msg of this.#queue)
142
+ const replay = [...this.#churnQueue, ...this.#signalQueue].sort((a, b) => a.order - b.order);
143
+ for (const msg of replay)
111
144
  ws.send(msg.text);
112
- this.#queue = [];
145
+ this.#churnQueue = [];
146
+ this.#signalQueue = [];
113
147
  this.#deps.onConnected?.();
114
148
  };
115
149
  ws.onmessage = (event) => {
116
150
  const data = event.data;
117
- void this.#onMessage(typeof data === 'string' ? data : String(data));
151
+ void this.#onMessage('string' === typeof data ? data : String(data));
118
152
  };
119
153
  ws.onclose = (event) => {
120
154
  this.#ws = undefined;
@@ -131,8 +165,7 @@ export class Transport {
131
165
  }
132
166
  this.#noteOutage();
133
167
  this.#noteInitialFailure();
134
- if (!this.#closed)
135
- nativeSetTimeout(() => this.#reopen(), RECONNECT_DELAY_MS);
168
+ this.#scheduleReopen();
136
169
  };
137
170
  ws.onerror = () => {
138
171
  ws.close();
@@ -142,6 +175,20 @@ export class Transport {
142
175
  * A scheduled reconnect — skipped if the SDK was torn down, or if a foreground-triggered reconnect
143
176
  * already opened a socket (guard against the throttled timer racing #onVisible into a duplicate).
144
177
  */
178
+ /**
179
+ * Schedule the next retry and grow the delay. Nothing is scheduled once the SDK is torn down.
180
+ *
181
+ * The delay grows AFTER scheduling, so the first retry of any outage is prompt (a daemon
182
+ * restarting must not cost 30 seconds) and only a persistent outage backs off.
183
+ */
184
+ #scheduleReopen() {
185
+ if (this.#closed)
186
+ return;
187
+ const delay = this.#reconnectDelay;
188
+ this.#reconnectDelay = nextReconnectDelay(delay);
189
+ const schedule = this.#deps.schedule ?? nativeSetTimeout;
190
+ schedule(() => this.#reopen(), delay);
191
+ }
145
192
  #reopen() {
146
193
  if (this.#closed || this.#ws !== undefined)
147
194
  return;
@@ -189,7 +236,7 @@ export class Transport {
189
236
  // failure mode where the cause is knowable and was being thrown away. Reply if we can recover
190
237
  // an id; a message without one is not a command we can answer at all.
191
238
  const id = parsed?.id;
192
- if (typeof id === 'string' && id.length > 0) {
239
+ if ('string' === typeof id && id.length > 0) {
193
240
  this.#sendRaw(safeStringify({
194
241
  kind: MessageKind.COMMAND_RESULT,
195
242
  id,
@@ -241,6 +288,7 @@ export class Transport {
241
288
  this.#sendRaw(safeStringify({ kind: MessageKind.EVENT, event }), {
242
289
  seq: event.seq,
243
290
  t: event.t,
291
+ type: event.type,
244
292
  });
245
293
  }
246
294
  /**
@@ -262,19 +310,31 @@ export class Transport {
262
310
  this.#ws.send(text);
263
311
  return;
264
312
  }
265
- if (this.#queue.length >= MAX_QUEUE) {
266
- // Full offline queue: drop the OLDEST and keep the newest (ring), so after reconnect the agent
267
- // replays RECENT activity instead of 500 stale events with the latest lost. Remember what went
268
- // so the loss can be declared on reconnect — an unmarked hole reads as "the app did nothing".
269
- // Only an evicted EVENT counts: a dropped COMMAND_RESULT is a reply the agent already gave up
270
- // waiting for, not a hole in what the app was observed to do.
271
- const evicted = this.#queue.shift();
313
+ if (this.#churnQueue.length + this.#signalQueue.length >= MAX_QUEUE) {
314
+ // Prefer sacrificing churn (high-volume, low-signal) over signal events. Falls back to signal
315
+ // FIFO only when the churn queue is empty — the buffer is bounded either way.
316
+ const evicted = this.#churnQueue.length > 0 ? this.#churnQueue.shift() : this.#signalQueue.shift();
272
317
  if (evicted?.event !== undefined) {
273
318
  this.#dropped += 1;
274
- this.#gap = evicted.event;
319
+ // Keep the EARLIEST drop, not the latest. Under plain FIFO the two were the same thing, so
320
+ // "latest evicted" was a precise boundary: everything before it was gone. Churn-first
321
+ // eviction breaks that — a churn event newer than a surviving signal event can be the one
322
+ // sacrificed, and stamping the marker with it would claim a hole that swallows events the
323
+ // agent is about to receive. The oldest drop is the honest floor: the hole starts no later
324
+ // than here. A truncation marker may over-state its reach; it must never under-state it.
325
+ const previous = this.#gap;
326
+ if (previous === undefined || evicted.event.t < previous.t)
327
+ this.#gap = evicted.event;
275
328
  }
276
329
  }
277
- this.#queue.push({ text, event });
330
+ const isChurn = event !== undefined && CHURN_TYPES.has(event.type);
331
+ const order = this.#insertOrder++;
332
+ if (isChurn) {
333
+ this.#churnQueue.push({ text, event, order });
334
+ }
335
+ else {
336
+ this.#signalQueue.push({ text, event, order });
337
+ }
278
338
  }
279
339
  /**
280
340
  * Tell the bridge the offline queue swallowed events, then consider the gap declared.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,11 +9,12 @@
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
- }
12
+ },
13
+ "./package.json": "./package.json"
13
14
  },
14
15
  "dependencies": {
15
16
  "@testing-library/dom": "^10.4.1",
16
- "@reticlehq/core": "2.4.0"
17
+ "@reticlehq/core": "2.6.0"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@tanstack/query-core": "^5.101.4",
@@ -61,6 +62,7 @@
61
62
  "build": "tsc -b",
62
63
  "typecheck": "tsc -b",
63
64
  "lint": "eslint src",
64
- "test:unit": "vitest run src --passWithNoTests"
65
+ "test:unit": "vitest run src --passWithNoTests",
66
+ "test:coverage": "vitest run src --passWithNoTests --coverage.enabled --coverage.provider=v8 --coverage.reporter=text-summary --coverage.include='src/**/*.ts' --coverage.exclude='src/**/*.test.ts'"
65
67
  }
66
68
  }