ocuclaw 1.3.0 → 1.3.2

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 (44) hide show
  1. package/README.md +3 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  3. package/dist/config/runtime-config.js +24 -15
  4. package/dist/domain/debug-store.js +18 -0
  5. package/dist/domain/glasses-display-system-prompt.js +52 -0
  6. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  7. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  8. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  9. package/dist/domain/prompt-channel-fragments.js +32 -0
  10. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  11. package/dist/gateway/gateway-timing-ledger.js +15 -3
  12. package/dist/gateway/openclaw-client.js +80 -3
  13. package/dist/index.js +22 -0
  14. package/dist/runtime/channel-two-hook.js +36 -0
  15. package/dist/runtime/container-env.js +41 -0
  16. package/dist/runtime/display-toggle-states.js +98 -0
  17. package/dist/runtime/plugin-version-service.js +23 -0
  18. package/dist/runtime/register-session-title-distiller.js +100 -0
  19. package/dist/runtime/relay-core.js +307 -68
  20. package/dist/runtime/relay-service.js +120 -13
  21. package/dist/runtime/relay-worker-entry.js +26 -0
  22. package/dist/runtime/relay-worker-protocol.js +0 -4
  23. package/dist/runtime/relay-worker-supervisor.js +43 -79
  24. package/dist/runtime/relay-worker-transport.js +41 -0
  25. package/dist/runtime/session-service.js +159 -15
  26. package/dist/runtime/session-title-distiller-budget.js +36 -0
  27. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  28. package/dist/runtime/session-title-distiller.js +354 -0
  29. package/dist/runtime/session-title-record.js +21 -0
  30. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  31. package/dist/tools/glasses-ui-cron.js +9 -3
  32. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  33. package/dist/tools/glasses-ui-recipes.js +13 -178
  34. package/dist/tools/glasses-ui-surfaces.js +8 -1
  35. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  36. package/dist/tools/glasses-ui-tool.js +98 -60
  37. package/dist/tools/session-title-tool.js +14 -76
  38. package/dist/tools/session-title-tool.test.js +53 -0
  39. package/dist/version.js +2 -2
  40. package/openclaw.plugin.json +9 -0
  41. package/package.json +6 -4
  42. package/skills/glasses-ui/SKILL.md +163 -0
  43. package/dist/runtime/downstream-server.js +0 -2057
  44. package/dist/runtime/plugin-update-service.js +0 -216
@@ -1,5 +1,10 @@
1
1
  import { createRuntimeConfig } from "../config/runtime-config.js";
2
2
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
3
+ import {
4
+ composeContainerLoopbackWarning,
5
+ isContainerEnvironment,
6
+ isLoopbackBindAddress,
7
+ } from "./container-env.js";
3
8
  import { createRelay as createPluginOwnedRelay } from "./relay-core.js";
4
9
 
5
10
  function normalizeLogger(logger) {
@@ -158,6 +163,13 @@ export function createOcuClawRelayService(opts = {}) {
158
163
  logger.info(
159
164
  `[ocuclaw] relay service started on ws://${config.wsBind}:${config.wsPort}`,
160
165
  );
166
+ const containerEnvProbe =
167
+ typeof opts.isContainerEnvironment === "function"
168
+ ? opts.isContainerEnvironment
169
+ : isContainerEnvironment;
170
+ if (isLoopbackBindAddress(config.wsBind) && containerEnvProbe()) {
171
+ logger.warn(composeContainerLoopbackWarning(config.wsBind, config.wsPort));
172
+ }
161
173
  return nextRelay;
162
174
  } catch (err) {
163
175
  clearSharedRelay(nextRelay);
@@ -291,34 +303,128 @@ export function createOcuClawRelayService(opts = {}) {
291
303
  trackedThrowawayKeys: [],
292
304
  };
293
305
  },
306
+ // These reads back the session-title tool, the distiller gates, and the
307
+ // Channel-2 before_prompt_build hook — all of which can run in a sibling
308
+ // plugin-register context whose own `relay` is null. Resolve the live
309
+ // (possibly shared) relay so they reach the running instance, mirroring
310
+ // hasConnectedAppClient(); otherwise the distiller skips every run and the
311
+ // Channel-2 stop-notices never fire.
294
312
  getSessionTitle(sessionKey) {
295
- if (relay && typeof relay.getSessionTitle === "function") {
296
- return relay.getSessionTitle(sessionKey);
313
+ const liveRelay = resolveLiveRelay();
314
+ if (liveRelay && typeof liveRelay.getSessionTitle === "function") {
315
+ return liveRelay.getSessionTitle(sessionKey);
297
316
  }
298
317
  return null;
299
318
  },
300
319
  hasRecordedUserMessage(sessionKey) {
301
- if (relay && typeof relay.hasRecordedUserMessage === "function") {
302
- return relay.hasRecordedUserMessage(sessionKey);
320
+ const liveRelay = resolveLiveRelay();
321
+ if (liveRelay && typeof liveRelay.hasRecordedUserMessage === "function") {
322
+ return liveRelay.hasRecordedUserMessage(sessionKey);
303
323
  }
304
- // Fail-closed when the relay isn't running: block titling.
324
+ // Fail-closed when no relay is running: block titling.
305
325
  return false;
306
326
  },
307
327
  isNeuralSessionNamesEnabled(sessionKey) {
308
- if (relay && typeof relay.isNeuralSessionNamesEnabled === "function") {
309
- return relay.isNeuralSessionNamesEnabled(sessionKey);
328
+ const liveRelay = resolveLiveRelay();
329
+ if (liveRelay && typeof liveRelay.isNeuralSessionNamesEnabled === "function") {
330
+ return liveRelay.isNeuralSessionNamesEnabled(sessionKey);
310
331
  }
311
332
  return true;
312
333
  },
313
334
  isSessionUserLocked(sessionKey) {
314
- if (relay && typeof relay.isSessionUserLocked === "function") {
315
- return relay.isSessionUserLocked(sessionKey);
335
+ const liveRelay = resolveLiveRelay();
336
+ if (liveRelay && typeof liveRelay.isSessionUserLocked === "function") {
337
+ return liveRelay.isSessionUserLocked(sessionKey);
338
+ }
339
+ return false;
340
+ },
341
+ getDisplayStartStates(sessionKey) {
342
+ const liveRelay = resolveLiveRelay();
343
+ if (liveRelay && typeof liveRelay.getDisplayStartStates === "function") {
344
+ return liveRelay.getDisplayStartStates(sessionKey);
345
+ }
346
+ return { emoji: false, pace: false };
347
+ },
348
+ getDisplayCurrentStates(sessionKey) {
349
+ const liveRelay = resolveLiveRelay();
350
+ if (liveRelay && typeof liveRelay.getDisplayCurrentStates === "function") {
351
+ return liveRelay.getDisplayCurrentStates(sessionKey);
352
+ }
353
+ return { emoji: false, pace: false };
354
+ },
355
+ // Distiller-sidecar passthroughs. Use the live relay (possibly a sibling
356
+ // register-context's shared relay) since the distiller may fire from a
357
+ // context whose own `relay` is null.
358
+ getSessionTitleRecord(sessionKey) {
359
+ const liveRelay = resolveLiveRelay();
360
+ if (liveRelay && typeof liveRelay.getSessionTitleRecord === "function") {
361
+ return liveRelay.getSessionTitleRecord(sessionKey);
362
+ }
363
+ return null;
364
+ },
365
+ isEvenAiSessionKey(sessionKey) {
366
+ const liveRelay = resolveLiveRelay();
367
+ if (liveRelay && typeof liveRelay.isEvenAiSessionKey === "function") {
368
+ return liveRelay.isEvenAiSessionKey(sessionKey);
316
369
  }
317
370
  return false;
318
371
  },
372
+ getRawMessages() {
373
+ const liveRelay = resolveLiveRelay();
374
+ if (liveRelay && typeof liveRelay.getRawMessages === "function") {
375
+ return liveRelay.getRawMessages();
376
+ }
377
+ return [];
378
+ },
379
+ getDistillerBudget() {
380
+ const liveRelay = resolveLiveRelay();
381
+ if (liveRelay && typeof liveRelay.getDistillerBudget === "function") {
382
+ return liveRelay.getDistillerBudget();
383
+ }
384
+ return null;
385
+ },
386
+ deleteDistillerSession(sessionKey) {
387
+ const liveRelay = resolveLiveRelay();
388
+ if (liveRelay && typeof liveRelay.deleteDistillerSession === "function") {
389
+ return liveRelay.deleteDistillerSession(sessionKey);
390
+ }
391
+ return Promise.resolve(null);
392
+ },
393
+ getStateDir() {
394
+ const liveRelay = resolveLiveRelay();
395
+ if (liveRelay && typeof liveRelay.getStateDir === "function") {
396
+ return liveRelay.getStateDir();
397
+ }
398
+ return opts.stateDir;
399
+ },
400
+ emitDebug(...args) {
401
+ const liveRelay = resolveLiveRelay();
402
+ if (liveRelay && typeof liveRelay.emitDebug === "function") {
403
+ return liveRelay.emitDebug(...args);
404
+ }
405
+ return undefined;
406
+ },
407
+ gatewayRequest(method, params, requestOpts) {
408
+ const liveRelay = resolveLiveRelay();
409
+ if (liveRelay && typeof liveRelay.gatewayRequest === "function") {
410
+ return liveRelay.gatewayRequest(method, params, requestOpts);
411
+ }
412
+ return Promise.reject(new Error("relay_not_running"));
413
+ },
414
+ onGatewayEvent(eventName, listener) {
415
+ const liveRelay = resolveLiveRelay();
416
+ if (liveRelay && typeof liveRelay.onGatewayEvent === "function") {
417
+ return liveRelay.onGatewayEvent(eventName, listener);
418
+ }
419
+ return () => {};
420
+ },
319
421
  peekSessionKey() {
320
- if (relay && typeof relay.peekSessionKey === "function") {
321
- return relay.peekSessionKey();
422
+ // The set_session_title tool reads this before writing; like the adjacent
423
+ // session-title accessors it must reach the live (possibly shared) relay,
424
+ // or an explicit rename from a sibling tool context fails no_active_session.
425
+ const liveRelay = resolveLiveRelay();
426
+ if (liveRelay && typeof liveRelay.peekSessionKey === "function") {
427
+ return liveRelay.peekSessionKey();
322
428
  }
323
429
  return null;
324
430
  },
@@ -328,8 +434,9 @@ export function createOcuClawRelayService(opts = {}) {
328
434
  }
329
435
  },
330
436
  setSessionTitle(sessionKey, title, opts) {
331
- if (relay && typeof relay.setSessionTitle === "function") {
332
- return relay.setSessionTitle(sessionKey, title, opts);
437
+ const liveRelay = resolveLiveRelay();
438
+ if (liveRelay && typeof liveRelay.setSessionTitle === "function") {
439
+ return liveRelay.setSessionTitle(sessionKey, title, opts);
333
440
  }
334
441
  return { ok: false, code: "relay_not_running" };
335
442
  },
@@ -5,10 +5,36 @@ if (!parentPort) {
5
5
  throw new Error("relay worker entry requires parentPort");
6
6
  }
7
7
 
8
+ function formatLogArgs(args) {
9
+ return args
10
+ .map((arg) => {
11
+ if (typeof arg === "string") return arg;
12
+ if (arg instanceof Error) return arg.message;
13
+ try {
14
+ return JSON.stringify(arg);
15
+ } catch {
16
+ return String(arg);
17
+ }
18
+ })
19
+ .join(" ");
20
+ }
21
+
22
+ function postWorkerLog(level, args) {
23
+ parentPort.postMessage({ kind: "worker.log", level, message: formatLogArgs(args) });
24
+ }
25
+
8
26
  const transport = createRelayWorkerTransport({
9
27
  postToMain(message) {
10
28
  parentPort.postMessage(message);
11
29
  },
30
+ // Worker-thread console output never reaches the gateway's log file;
31
+ // forward log lines to the supervisor, which owns the real plugin logger.
32
+ logger: {
33
+ info(...args) { postWorkerLog("info", args); },
34
+ warn(...args) { postWorkerLog("warn", args); },
35
+ error(...args) { postWorkerLog("error", args); },
36
+ debug(...args) { postWorkerLog("debug", args); },
37
+ },
12
38
  });
13
39
 
14
40
  parentPort.on("message", async (message) => {
@@ -16,10 +16,6 @@ export const APP_PROTOCOL = Object.freeze({
16
16
  readinessProbeAck: "ocuclaw.readiness.probe.ack",
17
17
  automationStateGet: "ocuclaw.automation.state.get",
18
18
  automationStateSnapshot: "ocuclaw.automation.state.snapshot",
19
- restartGateway: "ocuclaw.restartGateway",
20
- restartGatewayAck: "ocuclaw.restartGatewayAck",
21
- updatePlugin: "ocuclaw.updatePlugin",
22
- updatePluginResult: "ocuclaw.updatePluginResult",
23
19
  approvalRequest: "ocuclaw.approval.request",
24
20
  approvalResolved: "ocuclaw.approval.resolved",
25
21
  sessionContextSnapshot: "ocuclaw.session.context.snapshot",
@@ -92,33 +92,6 @@ function parseRequestIdFromRaw(raw) {
92
92
  return normalizeRequestId((parseFrame(raw) || {}).requestId);
93
93
  }
94
94
 
95
- function formatUpdatePluginResult(requestId, result) {
96
- const payload = { type: APP_PROTOCOL.updatePluginResult };
97
- if (requestId) payload.requestId = requestId;
98
- if (result && result.ok === true) {
99
- payload.ok = true;
100
- } else {
101
- payload.ok = false;
102
- if (result && typeof result.reason === "string") payload.reason = result.reason;
103
- if (result && typeof result.exitCode === "number") payload.exitCode = result.exitCode;
104
- if (result && typeof result.stderrTail === "string" && result.stderrTail.length > 0) {
105
- payload.stderrTail = result.stderrTail;
106
- }
107
- }
108
- return JSON.stringify(payload);
109
- }
110
-
111
- function formatRestartGatewayAck(requestId, result) {
112
- const payload = {
113
- type: APP_PROTOCOL.restartGatewayAck,
114
- ok: !!(result && result.ok),
115
- started: !!(result && result.started),
116
- };
117
- if (requestId) payload.requestId = requestId;
118
- if (result && typeof result.reason === "string") payload.reason = result.reason;
119
- return JSON.stringify(payload);
120
- }
121
-
122
95
  export function createRelayWorkerSupervisor(options = {}) {
123
96
  const logger = normalizeLogger(options.logger);
124
97
  const handler = options.handler || options.downstreamHandler || null;
@@ -546,57 +519,17 @@ export function createRelayWorkerSupervisor(options = {}) {
546
519
  logger.warn(`[relay-worker] ${message.message || "worker error"}`);
547
520
  return;
548
521
  }
522
+ if (message.kind === "worker.log") {
523
+ // Worker-thread console output never reaches the gateway's structured
524
+ // log file, so the worker forwards its log lines here instead.
525
+ const level =
526
+ message.level === "warn" || message.level === "error" || message.level === "debug"
527
+ ? message.level
528
+ : "info";
529
+ logger[level](typeof message.message === "string" ? message.message : String(message.message));
530
+ return;
531
+ }
549
532
  if (message.kind === "app.message") {
550
- if (message.operation === APP_PROTOCOL.updatePlugin) {
551
- if (typeof options.runPluginUpdate !== "function") {
552
- logger.warn("[relay-worker] updatePlugin requested but no runPluginUpdate is configured");
553
- return;
554
- }
555
- const requestId = parseRequestIdFromRaw(message.raw);
556
- (async () => {
557
- try {
558
- const result = await Promise.resolve(options.runPluginUpdate());
559
- postMainFrame("unicast", formatUpdatePluginResult(requestId, result), message.clientId);
560
- } catch (err) {
561
- logger.error(
562
- `[relay-worker] updatePlugin threw: ${err && err.message ? err.message : err}`,
563
- );
564
- postMainFrame(
565
- "unicast",
566
- formatUpdatePluginResult(requestId, { ok: false, reason: "spawn_failed" }),
567
- message.clientId,
568
- );
569
- }
570
- })();
571
- return;
572
- }
573
- if (message.operation === APP_PROTOCOL.restartGateway) {
574
- if (typeof options.runGatewayRestart !== "function") {
575
- logger.warn("[relay-worker] restartGateway requested but no runGatewayRestart is configured");
576
- return;
577
- }
578
- const requestId = parseRequestIdFromRaw(message.raw);
579
- (async () => {
580
- try {
581
- const result = await Promise.resolve(options.runGatewayRestart());
582
- postMainFrame("unicast", formatRestartGatewayAck(requestId, result), message.clientId);
583
- } catch (err) {
584
- logger.error(
585
- `[relay-worker] restartGateway threw: ${err && err.message ? err.message : err}`,
586
- );
587
- postMainFrame(
588
- "unicast",
589
- formatRestartGatewayAck(requestId, {
590
- ok: false,
591
- started: false,
592
- reason: "spawn_failed",
593
- }),
594
- message.clientId,
595
- );
596
- }
597
- })();
598
- return;
599
- }
600
533
  if (!handler || typeof handler.handleMessage !== "function") return;
601
534
  const processOptions = {};
602
535
  if (message.operation === "message.send" && message.requestId) {
@@ -658,7 +591,7 @@ export function createRelayWorkerSupervisor(options = {}) {
658
591
  clientName: message.clientName || null,
659
592
  clientVersion: message.clientVersion || null,
660
593
  sessionKey: message.sessionKey || null,
661
- readinessSnapshot: message.readinessSnapshot || null,
594
+ readinessSnapshot: normalizeIngestedReadinessSnapshot(message.readinessSnapshot),
662
595
  connectedAtMs: Number.isFinite(message.connectedAtMs)
663
596
  ? message.connectedAtMs
664
597
  : Date.now(),
@@ -758,7 +691,7 @@ export function createRelayWorkerSupervisor(options = {}) {
758
691
  if (message.kind === "client.readinessSnapshot") {
759
692
  const entry = clients.get(message.clientId);
760
693
  if (entry) {
761
- entry.readinessSnapshot = message.readinessSnapshot || null;
694
+ entry.readinessSnapshot = normalizeIngestedReadinessSnapshot(message.readinessSnapshot);
762
695
  entry.updatedAtMs = Number.isFinite(message.updatedAtMs)
763
696
  ? message.updatedAtMs
764
697
  : Date.now();
@@ -772,6 +705,21 @@ export function createRelayWorkerSupervisor(options = {}) {
772
705
  const pending = requestId ? pendingReadinessProbeRequests.get(requestId) : null;
773
706
  if (!pending || pending.targetClientId !== message.clientId) return;
774
707
  pendingReadinessProbeRequests.delete(requestId);
708
+ // Probe acks carry the app's CURRENT activeSessionKey — ground truth.
709
+ // Refresh the registry entry with it: the app's standalone snapshot
710
+ // republication can race a reconnecting socket and get lost, leaving the
711
+ // hello-frozen key here until the next app boot (quirk 8's second layer).
712
+ if (ack && ack.ok !== false && typeof ack.activeSessionKey === "string" && ack.activeSessionKey) {
713
+ const ackEntry = clients.get(message.clientId);
714
+ if (ackEntry && ackEntry.readinessSnapshot) {
715
+ ackEntry.readinessSnapshot = {
716
+ ...ackEntry.readinessSnapshot,
717
+ activeSessionKey: ack.activeSessionKey,
718
+ emittedAtMs: Number.isFinite(ack.emittedAtMs) ? ack.emittedAtMs : Date.now(),
719
+ };
720
+ ackEntry.updatedAtMs = Date.now();
721
+ }
722
+ }
775
723
  const protocol = clients.get(message.clientId) || {};
776
724
  const frame =
777
725
  handler && typeof handler.formatReadinessProbeAck === "function"
@@ -988,6 +936,22 @@ export function createRelayWorkerSupervisor(options = {}) {
988
936
  });
989
937
  }
990
938
 
939
+ // The WebUI's reconnect hello embeds its readiness snapshot WITHOUT
940
+ // emittedAtMs (toProtocolHelloReadinessSnapshotJson omits it), but the
941
+ // automation-state gate requires a finite emittedAtMs to count the client
942
+ // as published. Stamp ingest time so a reconnect hello counts as a
943
+ // publication — otherwise every relay restart leaves `inspect state`
944
+ // returning snapshot_unavailable until the sim/app client is cycled.
945
+ function normalizeIngestedReadinessSnapshot(snapshot) {
946
+ if (!snapshot || typeof snapshot !== "object") {
947
+ return null;
948
+ }
949
+ if (Number.isFinite(snapshot.emittedAtMs)) {
950
+ return snapshot;
951
+ }
952
+ return { ...snapshot, emittedAtMs: Date.now() };
953
+ }
954
+
991
955
  function getReadinessSnapshot() {
992
956
  const appClients = getConnectedAppEntries();
993
957
  const updatedAtMs = appClients.reduce((latest, entry) => {
@@ -56,6 +56,36 @@ export function createRelayWorkerTransport(options = {}) {
56
56
  let httpServer = null;
57
57
  let wss = null;
58
58
  let nextClientId = 1;
59
+ // Invalid-token rejects are the one connection log an internet-facing
60
+ // misconfiguration could flood, so they are rate-limited per remote address.
61
+ const TOKEN_REJECT_LOG_WINDOW_MS = 60000;
62
+ const TOKEN_REJECT_LOG_MAX_ADDRESSES = 100;
63
+ const tokenRejectLogState = new Map();
64
+
65
+ function logTokenReject(remoteAddress) {
66
+ const at = now();
67
+ let state = tokenRejectLogState.get(remoteAddress);
68
+ if (!state) {
69
+ if (tokenRejectLogState.size >= TOKEN_REJECT_LOG_MAX_ADDRESSES) {
70
+ tokenRejectLogState.clear();
71
+ }
72
+ state = { lastLogAtMs: null, suppressedCount: 0 };
73
+ tokenRejectLogState.set(remoteAddress, state);
74
+ }
75
+ if (state.lastLogAtMs !== null && at - state.lastLogAtMs < TOKEN_REJECT_LOG_WINDOW_MS) {
76
+ state.suppressedCount += 1;
77
+ return;
78
+ }
79
+ const suffix =
80
+ state.suppressedCount > 0
81
+ ? ` (+${state.suppressedCount} more rejected from this address since last log)`
82
+ : "";
83
+ state.lastLogAtMs = at;
84
+ state.suppressedCount = 0;
85
+ logger.warn(
86
+ `[ocuclaw] relay rejected connection: invalid token remote=${remoteAddress}${suffix}`,
87
+ );
88
+ }
59
89
  let expireTimer = null;
60
90
  let healthTimer = null;
61
91
  let loopDelayMonitor = null;
@@ -894,11 +924,17 @@ export function createRelayWorkerTransport(options = {}) {
894
924
  });
895
925
  wss.on("connection", (ws, req) => {
896
926
  const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
927
+ const remoteAddress = (req.socket && req.socket.remoteAddress) || "unknown";
897
928
  if (requestUrl.searchParams.get("token") !== manifest.relayToken) {
929
+ logTokenReject(remoteAddress);
898
930
  ws.close(4001, "invalid_token");
899
931
  return;
900
932
  }
901
933
  const clientId = `worker-client-${nextClientId++}`;
934
+ const connectedAtMs = now();
935
+ logger.info(
936
+ `[ocuclaw] relay client connected clientId=${clientId} remote=${remoteAddress}`,
937
+ );
902
938
  clients.set(clientId, ws);
903
939
  protocolState.set(clientId, {
904
940
  protocolVersion: null,
@@ -925,6 +961,11 @@ export function createRelayWorkerTransport(options = {}) {
925
961
  : Buffer.isBuffer(reason)
926
962
  ? reason.toString("utf8")
927
963
  : String(reason);
964
+ logger.info(
965
+ `[ocuclaw] relay client disconnected clientId=${clientId} remote=${remoteAddress} code=${
966
+ Number.isFinite(code) ? code : "none"
967
+ } lifetimeMs=${Math.max(0, now() - connectedAtMs)}`,
968
+ );
928
969
  postToMain({
929
970
  kind: "client.disconnected",
930
971
  clientId,