@lelouchhe/webagent 0.4.0 → 0.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.
@@ -3,28 +3,37 @@
3
3
  *
4
4
  * Tracks per-client metadata that survives SSE disconnect:
5
5
  * - capabilities advertised by the client on /hello.
6
- * - focus: clientId's current sessionId (set via /focus). Used by push
7
- * visibility suppression to know which session each client is viewing.
6
+ * - visible / active / visibleSince: identity-layer visibility state used
7
+ * by TTS dispatch (voice branch) and push suppression (main).
8
8
  *
9
- * Lifecycle: clients call /hello on SSE connect (register) and /focus when
10
- * they switch sessions. SSE disconnect does not remove a client — it stays
11
- * in the registry until an explicit /goodbye or TTL eviction (caller's
12
- * responsibility). This lets visibility state outlive transient drops.
9
+ * Lifecycle: clients call /hello on SSE connect (register) and POST
10
+ * /visibility on visibilitychange + 15s heartbeat. SSE disconnect does
11
+ * not remove a client — it stays in the registry until an explicit
12
+ * /goodbye or TTL eviction (caller's responsibility). This lets visibility
13
+ * state outlive transient drops.
13
14
  */
14
15
  export class ClientRegistry {
15
16
  clients = new Map();
17
+ visibilityTtlMs;
18
+ now;
19
+ constructor(options = {}) {
20
+ this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
21
+ this.now = options.now ?? Date.now;
22
+ }
16
23
  register(id, data) {
17
24
  const existing = this.clients.get(id);
18
25
  if (existing) {
19
26
  existing.capabilities = data.capabilities;
20
- existing.lastSeen = Date.now();
27
+ existing.lastSeen = this.now();
21
28
  return existing;
22
29
  }
23
30
  const entry = {
24
31
  id,
25
32
  capabilities: data.capabilities,
26
- focus: null,
27
- lastSeen: Date.now(),
33
+ visible: false,
34
+ active: null,
35
+ visibleSince: 0,
36
+ lastSeen: this.now(),
28
37
  };
29
38
  this.clients.set(id, entry);
30
39
  return entry;
@@ -32,24 +41,104 @@ export class ClientRegistry {
32
41
  remove(id) {
33
42
  this.clients.delete(id);
34
43
  }
35
- setFocus(id, sessionId) {
44
+ /**
45
+ * Atomic visibility setter. Mirrors PushService.updateClient semantics:
46
+ * - `visible` omitted = preserve; bool = set (and stamp/clear visibleSince).
47
+ * - `active` omitted = preserve; null = clear; string = replace.
48
+ * - Returns `becameVisibleFor=X` only on first transition into
49
+ * (visible:true, active:X) — heartbeat refreshes return null so
50
+ * callers can fire edge-triggered side effects exactly once.
51
+ * - Session-switch while visible (active X→Y) restarts the TTL clock
52
+ * even when the patch doesn't carry an explicit visible:true.
53
+ *
54
+ * No-op on unknown client.
55
+ */
56
+ setVisibility(id, patch) {
36
57
  const entry = this.clients.get(id);
37
58
  if (!entry)
38
- return;
39
- entry.focus = sessionId;
40
- entry.lastSeen = Date.now();
59
+ return { becameVisibleFor: null };
60
+ const wasVisibleForSession = entry.visible && entry.active != null ? entry.active : null;
61
+ if (patch.visible !== undefined) {
62
+ entry.visible = patch.visible;
63
+ entry.visibleSince = patch.visible ? this.now() : 0;
64
+ }
65
+ if (patch.active !== undefined) {
66
+ entry.active = patch.active;
67
+ }
68
+ const becameVisibleFor = entry.visible &&
69
+ entry.active != null &&
70
+ entry.active !== wasVisibleForSession
71
+ ? entry.active
72
+ : null;
73
+ if (becameVisibleFor) {
74
+ // Any transition into "visible + active=X" restarts TTL — including
75
+ // session-switches that arrive without an explicit visible:true.
76
+ entry.visibleSince = this.now();
77
+ }
78
+ entry.lastSeen = this.now();
79
+ return { becameVisibleFor };
80
+ }
81
+ /** Is this specific client currently visible & viewing `sessionId` & fresh? */
82
+ isVisibleForSession(id, sessionId) {
83
+ const entry = this.clients.get(id);
84
+ if (!entry)
85
+ return false;
86
+ if (!entry.visible)
87
+ return false;
88
+ if (entry.active !== sessionId)
89
+ return false;
90
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
91
+ return false;
92
+ return true;
93
+ }
94
+ /** Is at least one fresh visible client viewing `sessionId`? */
95
+ isSessionVisibleToAnyClient(sessionId) {
96
+ const now = this.now();
97
+ for (const e of this.clients.values()) {
98
+ if (!e.visible)
99
+ continue;
100
+ if (e.active !== sessionId)
101
+ continue;
102
+ if (now - e.visibleSince > this.visibilityTtlMs)
103
+ continue;
104
+ return true;
105
+ }
106
+ return false;
107
+ }
108
+ /** Is this specific client currently fresh-visible (any session)? */
109
+ isClientVisible(id) {
110
+ const entry = this.clients.get(id);
111
+ if (!entry)
112
+ return false;
113
+ if (!entry.visible)
114
+ return false;
115
+ if (this.now() - entry.visibleSince > this.visibilityTtlMs)
116
+ return false;
117
+ return true;
118
+ }
119
+ /** Is at least one fresh visible client connected (any session)? */
120
+ hasAnyVisibleClient() {
121
+ const now = this.now();
122
+ for (const e of this.clients.values()) {
123
+ if (!e.visible)
124
+ continue;
125
+ if (now - e.visibleSince > this.visibilityTtlMs)
126
+ continue;
127
+ return true;
128
+ }
129
+ return false;
41
130
  }
42
131
  updateCapabilities(id, caps) {
43
132
  const entry = this.clients.get(id);
44
133
  if (!entry)
45
134
  return;
46
135
  entry.capabilities = caps;
47
- entry.lastSeen = Date.now();
136
+ entry.lastSeen = this.now();
48
137
  }
49
138
  touch(id) {
50
139
  const entry = this.clients.get(id);
51
140
  if (entry)
52
- entry.lastSeen = Date.now();
141
+ entry.lastSeen = this.now();
53
142
  }
54
143
  get(id) {
55
144
  return this.clients.get(id);
package/lib/config.js CHANGED
@@ -3,6 +3,13 @@ import { parse as parseTOML } from "smol-toml";
3
3
  import { z } from "zod";
4
4
  export const ConfigSchema = z.object({
5
5
  port: z.number().int().positive().default(6800),
6
+ // Network interface to bind. Default "127.0.0.1" = loopback only
7
+ // (no LAN exposure). Set to "0.0.0.0" to listen on all IPv4
8
+ // interfaces, "::" for IPv6/dual-stack, or a specific NIC IP
9
+ // (e.g. "192.168.1.10") to bind one interface on a multi-homed
10
+ // host. Note: "localhost" works but resolves via DNS and may
11
+ // pick IPv6 (`::1`) over IPv4 — prefer the explicit IP form.
12
+ host: z.string().default("127.0.0.1"),
6
13
  data_dir: z.string().default("data"),
7
14
  default_cwd: z.string().default(process.cwd()),
8
15
  public_dir: z.string().default("dist"),
@@ -35,7 +42,7 @@ export const ConfigSchema = z.object({
35
42
  }),
36
43
  // [title] — title generation sub-session configuration.
37
44
  //
38
- // `model` is an array of case-insensitive substring patterns. When the
45
+ // `models` is an array of case-insensitive substring patterns. When the
39
46
  // title sub-session is created, we look at the model list the agent
40
47
  // reports (ACP `availableModels`) and pick the first model whose id
41
48
  // matches any pattern in order. Match → call `setConfigOption` with
@@ -50,17 +57,17 @@ export const ConfigSchema = z.object({
50
57
  // - "flash" → Google Gemini (gemini-*-flash)
51
58
  // - "lite" → Cohere, generic
52
59
  //
53
- // Set `model = []` to disable substring matching entirely and always
60
+ // Set `models = []` to disable substring matching entirely and always
54
61
  // inherit the agent's default model. To pin one specific model, pass a
55
- // single-element array: `model = ["claude-haiku-4.5"]`.
62
+ // single-element array: `models = ["claude-haiku-4.5"]`.
56
63
  title: z
57
64
  .object({
58
- model: z
65
+ models: z
59
66
  .array(z.string())
60
67
  .default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
61
68
  })
62
69
  .default({
63
- model: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
70
+ models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
64
71
  }),
65
72
  // [debug] — frontend log level.
66
73
  // level ∈ off | debug | info | warn | error. Default "off".
@@ -14,7 +14,9 @@ function handleConfigLikeEvent(event, sessions, store) {
14
14
  if (event.configOptions.length)
15
15
  sessions.cachedConfigOptions = event.configOptions;
16
16
  for (const opt of event.configOptions) {
17
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
17
+ if (typeof opt.currentValue === "string") {
18
+ store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
19
+ }
18
20
  }
19
21
  }
20
22
  function handleMessageChunk(event, sessions) {
@@ -0,0 +1,64 @@
1
+ function valid(width, height) {
2
+ if (!Number.isInteger(width) || !Number.isInteger(height))
3
+ return null;
4
+ if (width <= 0 || height <= 0)
5
+ return null;
6
+ return { width, height };
7
+ }
8
+ export function readImageDimensions(buf) {
9
+ return (readPngDimensions(buf) ?? readGifDimensions(buf) ?? readJpegDimensions(buf));
10
+ }
11
+ function readPngDimensions(buf) {
12
+ // PNG: signature + IHDR width/height at fixed offsets.
13
+ if (buf.length >= 24 &&
14
+ buf[0] === 0x89 &&
15
+ buf[1] === 0x50 &&
16
+ buf[2] === 0x4e &&
17
+ buf[3] === 0x47 &&
18
+ buf.toString("ascii", 12, 16) === "IHDR") {
19
+ return valid(buf.readUInt32BE(16), buf.readUInt32BE(20));
20
+ }
21
+ return null;
22
+ }
23
+ function readGifDimensions(buf) {
24
+ // GIF87a/GIF89a: logical screen width/height, little-endian.
25
+ if (buf.length >= 10 &&
26
+ (buf.toString("ascii", 0, 6) === "GIF87a" ||
27
+ buf.toString("ascii", 0, 6) === "GIF89a")) {
28
+ return valid(buf.readUInt16LE(6), buf.readUInt16LE(8));
29
+ }
30
+ return null;
31
+ }
32
+ function isJpegSofMarker(marker) {
33
+ return ((marker >= 0xc0 && marker <= 0xc3) ||
34
+ (marker >= 0xc5 && marker <= 0xc7) ||
35
+ (marker >= 0xc9 && marker <= 0xcb) ||
36
+ (marker >= 0xcd && marker <= 0xcf));
37
+ }
38
+ function readJpegDimensions(buf) {
39
+ // JPEG: scan marker segments until a SOF marker carrying dimensions.
40
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
41
+ let offset = 2;
42
+ while (offset + 3 < buf.length) {
43
+ if (buf[offset] !== 0xff) {
44
+ offset++;
45
+ continue;
46
+ }
47
+ while (offset < buf.length && buf[offset] === 0xff)
48
+ offset++;
49
+ const marker = buf[offset++];
50
+ if (marker === 0xd9 || marker === 0xda)
51
+ break;
52
+ if (offset + 1 >= buf.length)
53
+ break;
54
+ const length = buf.readUInt16BE(offset);
55
+ if (length < 2 || offset + length > buf.length)
56
+ break;
57
+ if (isJpegSofMarker(marker) && length >= 7) {
58
+ return valid(buf.readUInt16BE(offset + 5), buf.readUInt16BE(offset + 3));
59
+ }
60
+ offset += length;
61
+ }
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,17 @@
1
+ /** Find the first available model whose id matches any pattern. */
2
+ export function pickModelByPatterns(configOptions, patterns) {
3
+ const normalized = patterns
4
+ .map((p) => p.trim().toLowerCase())
5
+ .filter((p) => p.length > 0);
6
+ if (normalized.length === 0)
7
+ return null;
8
+ const modelOpt = configOptions.find((c) => c.id === "model" && "options" in c);
9
+ if (!modelOpt || modelOpt.options.length === 0)
10
+ return null;
11
+ for (const pattern of normalized) {
12
+ const hit = modelOpt.options.find((o) => o.value.toLowerCase().includes(pattern));
13
+ if (hit)
14
+ return hit.value;
15
+ }
16
+ return null;
17
+ }
package/lib/preflight.js CHANGED
@@ -120,52 +120,71 @@ function checkAgent(agentCmd) {
120
120
  };
121
121
  }
122
122
  /**
123
- * Probe whether `port` can be bound on 0.0.0.0 (matching what
124
- * `server.listen` actually uses). Listens, then closes immediately.
125
- * There's a tiny race window between close and the real
126
- * server.listen() — that's fine for diagnostics: the goal is a friendly
127
- * "port already in use" hint, not a hard guarantee.
123
+ * Probe whether `host:port` can be bound (matching what `server.listen`
124
+ * actually uses). Listens, then closes immediately. There's a tiny
125
+ * race window between close and the real server.listen() — that's
126
+ * fine for diagnostics: the goal is a friendly hint, not a hard
127
+ * guarantee.
128
128
  *
129
129
  * Port 0 means "let the OS pick"; we treat it as always-free.
130
+ *
131
+ * Probing the same host as the real server matters: a foreign
132
+ * listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
133
+ * bind fails when wildcard already bound), so binding the configured
134
+ * host catches conflicts and surfaces EADDRNOTAVAIL when the user
135
+ * typo'd an IP that isn't on any local interface.
130
136
  */
131
- async function checkPort(port) {
132
- if (port === 0) {
133
- return { ok: true, name: "port", detail: "0 (OS-assigned)" };
134
- }
135
- // Probe must bind to the same address family as the real server
136
- // (server.ts uses "0.0.0.0"). Probing 127.0.0.1 lets a foreign
137
- // listener on 0.0.0.0:PORT slip past preflight and only surface as
138
- // EADDRINUSE during the real server.listen() — exactly the case
139
- // we're trying to catch.
137
+ async function checkPort(port, host) {
138
+ const label = `${host}:${port}`;
139
+ // port=0 still gets probed: the OS picks any free port, but bind can
140
+ // still fail with EADDRNOTAVAIL if `host` isn't on any local
141
+ // interface that's exactly the typo we want to catch.
140
142
  const result = await new Promise((settle) => {
141
143
  const probe = createServer();
142
144
  probe.once("error", (err) => {
143
145
  settle({ code: err.code ?? "unknown" });
144
146
  });
145
- probe.listen(port, "0.0.0.0", () => {
147
+ probe.listen(port, host, () => {
148
+ const addr = probe.address();
149
+ const assigned = typeof addr === "object" && addr ? addr.port : port;
146
150
  probe.close(() => {
147
- settle({});
151
+ settle({ assigned });
148
152
  });
149
153
  });
150
154
  });
151
155
  if (!result.code) {
152
- return { ok: true, name: "port", detail: String(port) };
156
+ if (port === 0) {
157
+ return {
158
+ ok: true,
159
+ name: "port",
160
+ detail: `${host}:0 (OS-assigned → ${result.assigned})`,
161
+ };
162
+ }
163
+ return { ok: true, name: "port", detail: label };
153
164
  }
154
165
  if (result.code === "EADDRINUSE") {
155
166
  return {
156
167
  ok: false,
157
168
  name: "port",
158
- detail: `${port} (in use)`,
159
- hint: `port ${port} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
169
+ detail: `${label} (in use)`,
170
+ hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
160
171
  ? `netstat -ano | findstr :${port}`
161
172
  : `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
162
173
  };
163
174
  }
175
+ if (result.code === "EADDRNOTAVAIL") {
176
+ return {
177
+ ok: false,
178
+ name: "port",
179
+ detail: `${label} (${result.code})`,
180
+ hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
181
+ };
182
+ }
164
183
  return {
165
184
  ok: false,
166
185
  name: "port",
167
- detail: `${port} (${result.code})`,
168
- hint: `cannot bind port ${port}: ${result.code}\ncheck firewall / permissions, or change \`port\` in config.toml.`,
186
+ detail: `${label} (${result.code})`,
187
+ hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
169
188
  };
170
189
  }
171
190
  /**
@@ -179,7 +198,7 @@ export async function runPreflight(opts) {
179
198
  checks.push(checkDataDir(opts.data_dir));
180
199
  const agent = checkAgent(opts.agent_cmd);
181
200
  checks.push(agent);
182
- checks.push(await checkPort(opts.port));
201
+ checks.push(await checkPort(opts.port, opts.host));
183
202
  for (const c of checks) {
184
203
  if (c.ok)
185
204
  printOk(c);
@@ -46,18 +46,14 @@ export function isAppleEndpoint(endpoint) {
46
46
  return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
47
47
  }
48
48
  function emptyClientState() {
49
- return { visible: false, sessionId: null, endpoint: null, visibleSince: 0 };
49
+ return { endpoint: null };
50
50
  }
51
51
  export class PushService {
52
52
  store;
53
53
  vapidKeys;
54
54
  /**
55
- * Consolidated per-client state. Previously 3 separate Maps
56
- * (clientVisibility/clientEndpoints/clientSessions) which drifted under
57
- * partial updates. v2 merges them; `visibleSince` stamps when the client
58
- * transitioned to visible so the server can TTL-expire "ghost" visibility
59
- * records left by iOS PWA process suspension (where the client never gets
60
- * to POST visible:false).
55
+ * Per-client transport state only the push endpoint. Visibility lives
56
+ * in ClientRegistry as of Plan C Step 4.
61
57
  */
62
58
  clients = new Map();
63
59
  /** endpoint → consecutive failure count (absent or 0 = healthy) */
@@ -65,7 +61,8 @@ export class PushService {
65
61
  globalVisibilitySuppression;
66
62
  visibilityTtlMs;
67
63
  now;
68
- constructor(store, dataDir, vapidSubject, options = {}) {
64
+ clientRegistry;
65
+ constructor(store, dataDir, vapidSubject, options) {
69
66
  this.store = store;
70
67
  this.vapidKeys = this.loadOrGenerateKeys(dataDir);
71
68
  webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
@@ -73,6 +70,7 @@ export class PushService {
73
70
  options.globalVisibilitySuppression ?? true;
74
71
  this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
75
72
  this.now = options.now ?? (() => Date.now());
73
+ this.clientRegistry = options.clientRegistry;
76
74
  }
77
75
  // ---------------------------------------------------------------------------
78
76
  // VAPID keys
@@ -123,43 +121,18 @@ export class PushService {
123
121
  return { kind: "notify", title, body, tag, data: { sessionId } };
124
122
  }
125
123
  // ---------------------------------------------------------------------------
126
- // Client visibility tracking
124
+ // Endpoint mapping (transport-only as of Plan C Step 4)
127
125
  // ---------------------------------------------------------------------------
128
126
  /**
129
- * Atomic consolidated setter. All visibility/session/endpoint updates
130
- * should route through here. Callers distinguish "preserve" from "clear"
131
- * by omitting the key vs passing `null`. Returns an edge flag so the
132
- * caller can fire edge-triggered side effects (e.g. sendClose) without
133
- * double-firing on every heartbeat refresh.
127
+ * Set the push endpoint for a client. Identity-layer state (visibility,
128
+ * active session) goes through ClientRegistry.setVisibility, not here.
134
129
  */
135
130
  updateClient(clientId, patch) {
136
131
  const prev = this.clients.get(clientId) ?? emptyClientState();
137
- const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
138
132
  const next = { ...prev };
139
- if (patch.visible !== undefined) {
140
- next.visible = patch.visible;
141
- next.visibleSince = patch.visible ? this.now() : 0;
142
- }
143
- if (patch.sessionId !== undefined)
144
- next.sessionId = patch.sessionId;
145
133
  if (patch.endpoint !== undefined)
146
134
  next.endpoint = patch.endpoint;
147
- const becameVisibleForSession = next.visible &&
148
- next.sessionId != null &&
149
- next.sessionId !== wasVisibleForSession
150
- ? next.sessionId
151
- : null;
152
- // Any transition into "visible + session X" restarts the TTL clock,
153
- // including a session-switch that arrives without an explicit
154
- // visible:true in the patch (e.g. a session_created POST that only
155
- // carries sessionId). Otherwise the TTL would keep counting from the
156
- // previous session's first-visible moment and could prematurely
157
- // declare the newly-focused session "stale".
158
- if (becameVisibleForSession) {
159
- next.visibleSince = this.now();
160
- }
161
135
  this.clients.set(clientId, next);
162
- return { becameVisibleForSession };
163
136
  }
164
137
  /**
165
138
  * Read-only snapshot for tests and diagnostics. Do NOT mutate the
@@ -168,40 +141,29 @@ export class PushService {
168
141
  getClientState(clientId) {
169
142
  return this.clients.get(clientId) ?? null;
170
143
  }
171
- /** @deprecated Shim: prefer `updateClient({ visible })`. */
172
- setClientVisibility(clientId, visible) {
173
- this.updateClient(clientId, { visible });
174
- }
175
- /** @deprecated Shim: prefer `updateClient({ sessionId })`. */
176
- setClientSession(clientId, sessionId) {
177
- this.updateClient(clientId, { sessionId });
178
- }
179
- /** @deprecated Shim: prefer `updateClient({ endpoint })`. */
180
144
  registerClient(clientId, endpoint) {
181
145
  this.updateClient(clientId, { endpoint });
182
146
  }
183
147
  removeClient(clientId) {
184
148
  this.clients.delete(clientId);
149
+ // Disconnect also wipes identity-layer state so visibility queries don't
150
+ // leak past the SSE lifetime. Production calls removeClient on SSE close
151
+ // (see sse-manager); tests expect the same.
152
+ this.clientRegistry.remove(clientId);
185
153
  }
186
154
  hasVisibleClient() {
187
- const now = this.now();
188
- for (const s of this.clients.values()) {
189
- if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
190
- return true;
191
- }
192
- return false;
155
+ return this.clientRegistry.hasAnyVisibleClient();
193
156
  }
194
157
  /** Check if a specific endpoint has at least one visible (non-stale) client. */
195
158
  isEndpointVisible(endpoint) {
196
- const now = this.now();
197
- for (const s of this.clients.values()) {
159
+ // Identity (visible) comes from registry, transport (endpoint↔clientId)
160
+ // stays with pushService.clients.
161
+ const reg = this.clientRegistry;
162
+ for (const [clientId, s] of this.clients) {
198
163
  if (s.endpoint !== endpoint)
199
164
  continue;
200
- if (!s.visible)
201
- continue;
202
- if (now - s.visibleSince > this.visibilityTtlMs)
203
- continue;
204
- return true;
165
+ if (reg.isClientVisible(clientId))
166
+ return true;
205
167
  }
206
168
  return false;
207
169
  }
@@ -219,17 +181,7 @@ export class PushService {
219
181
  isSessionVisibleToAnyClient(sessionId) {
220
182
  if (!this.globalVisibilitySuppression)
221
183
  return false;
222
- const now = this.now();
223
- for (const s of this.clients.values()) {
224
- if (!s.visible)
225
- continue;
226
- if (s.sessionId !== sessionId)
227
- continue;
228
- if (now - s.visibleSince > this.visibilityTtlMs)
229
- continue;
230
- return true;
231
- }
232
- return false;
184
+ return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
233
185
  }
234
186
  // ---------------------------------------------------------------------------
235
187
  // High-level: decide whether to push, and if so, send