@camstack/system 1.1.18 → 1.1.20

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.
@@ -3360,6 +3360,133 @@ function createLocalTransport() {
3360
3360
  };
3361
3361
  }
3362
3362
  //#endregion
3363
+ //#region src/kernel/moleculer/event-bus-core.ts
3364
+ /**
3365
+ * @param retainRecent - `true` ONLY for the hub main broker bus (it serves
3366
+ * `getRecent`). Defaults to `false` — the safe one-shot default for child
3367
+ * runners + agents, which retain nothing.
3368
+ */
3369
+ function createSharedBusState(retainRecent = false) {
3370
+ return {
3371
+ handlers: /* @__PURE__ */ new Map(),
3372
+ recent: [],
3373
+ retainRecent
3374
+ };
3375
+ }
3376
+ function matchesPattern(pattern, category) {
3377
+ if (pattern === "*" || pattern === "**") return true;
3378
+ if (pattern.endsWith(".**")) return category.startsWith(pattern.slice(0, -3));
3379
+ if (pattern.endsWith(".*")) return category.startsWith(pattern.slice(0, -2));
3380
+ return pattern === category;
3381
+ }
3382
+ function extractCategoryPattern(filter) {
3383
+ if (typeof filter === "string") return filter;
3384
+ if (filter && typeof filter === "object" && "category" in filter) {
3385
+ const cat = filter.category;
3386
+ if (typeof cat === "string") return cat;
3387
+ if (Array.isArray(cat) && cat.length > 0) return String(cat[0]);
3388
+ }
3389
+ return "*";
3390
+ }
3391
+ /**
3392
+ * Match an event against the full `EventFilter`. The Map-keyed subscribe path
3393
+ * only handles the category pattern (deciding which handler set to fan out to);
3394
+ * this function applies the remaining scope dimensions (`agentId`, `addonId`,
3395
+ * `deviceId`, `source`, `since`) so per-device / per-addon subscribers don't
3396
+ * receive sibling events.
3397
+ */
3398
+ function matchesEventFilter(event, filter) {
3399
+ if (!filter || typeof filter === "string") return true;
3400
+ if (filter.source) {
3401
+ if (event.source.type !== filter.source.type || event.source.id !== filter.source.id) return false;
3402
+ }
3403
+ if (filter.agentId) {
3404
+ const eventNodeId = event.source.nodeId;
3405
+ if (!eventNodeId || eventNodeId !== filter.agentId && !eventNodeId.startsWith(`${filter.agentId}/`)) return false;
3406
+ }
3407
+ if (filter.addonId) {
3408
+ if ((event.source.addonId ?? (event.source.type === "addon" ? String(event.source.id) : void 0)) !== filter.addonId) return false;
3409
+ }
3410
+ if (filter.deviceId !== void 0) {
3411
+ if ((event.source.deviceId ?? (event.source.type === "device" ? Number(event.source.id) : void 0)) !== filter.deviceId) return false;
3412
+ }
3413
+ if (filter.since && event.timestamp < filter.since) return false;
3414
+ return true;
3415
+ }
3416
+ /**
3417
+ * Categories filtered out of the `recent[]` ring buffer.
3418
+ *
3419
+ * High-frequency, low-value-for-the-operator categories — periodic metrics
3420
+ * snapshots, per-frame inference traces, raw motion-mask dumps, model-download
3421
+ * progress, etc. They still fan out to live subscribers (UI charts that
3422
+ * explicitly listen for them), but do NOT enter the recent-events ring so
3423
+ * meaningful events (motion / phase transitions / device lifecycle / addon
3424
+ * lifecycle / recording / detection.event) survive in the last-N window without
3425
+ * being displaced by per-frame noise.
3426
+ */
3427
+ var RING_BUFFER_DENY_PATTERNS = [
3428
+ "pipeline.camera-metrics-snapshot",
3429
+ "pipeline.runner-load-snapshot",
3430
+ "pipeline.engine-metrics-snapshot",
3431
+ "pipeline.inference-result",
3432
+ "pipeline.trace",
3433
+ "pipeline.progress",
3434
+ "stream-broker.metrics-snapshot",
3435
+ "metrics.node-resources-snapshot",
3436
+ "metrics.node-processes-snapshot",
3437
+ "cluster.topology-snapshot",
3438
+ "detection.motion-analysis",
3439
+ "detection.motion-zones-raw",
3440
+ "detection.result",
3441
+ "pipeline.audio-inference-result",
3442
+ "platform-probe.phase",
3443
+ "benchmark.progress",
3444
+ "model.download.progress",
3445
+ "pipeline-analytics.frame-tracked",
3446
+ "pipeline-analytics.detection-event",
3447
+ "pipeline-analytics.track-started",
3448
+ "pipeline-analytics.track-ended",
3449
+ "motion.on-motion-changed",
3450
+ "capability.binding-changed"
3451
+ ];
3452
+ /**
3453
+ * Hard cap on the `recent[]` buffer size. Even audit-grade categories that
3454
+ * are NOT on the deny-list (e.g. `device.state-changed`) must not accumulate
3455
+ * without bound — this is the last-N window, not a durable log. Without this
3456
+ * cap the buffer grew forever (~26 MB/h/process across every broker, agent,
3457
+ * and UDS-child bus), filling the hub to 16 GB.
3458
+ */
3459
+ var MAX_RECENT_EVENTS = 1e3;
3460
+ function isHighFrequencyCategory(category) {
3461
+ for (const pattern of RING_BUFFER_DENY_PATTERNS) if (matchesPattern(pattern, category)) return true;
3462
+ return false;
3463
+ }
3464
+ /**
3465
+ * Deliver `event` to all matching local subscribers and — ONLY on a bus that
3466
+ * opted into retention (`state.retainRecent`, i.e. the hub main process) and
3467
+ * unless the event is high-frequency — append it to the bounded `recent[]`
3468
+ * ring.
3469
+ *
3470
+ * On a non-retaining bus (child runners, agents) the retention block is skipped
3471
+ * entirely: events are one-shot / fire-and-forget, still fanned out to local
3472
+ * subscribers but never accumulated. `getRecent` on such a bus returns `[]`.
3473
+ */
3474
+ function deliverShared(state, event) {
3475
+ if (state.retainRecent && !isHighFrequencyCategory(event.category)) {
3476
+ state.recent.push(event);
3477
+ if (state.recent.length > 1e3) state.recent.splice(0, state.recent.length - MAX_RECENT_EVENTS);
3478
+ }
3479
+ for (const [pattern, set] of state.handlers) {
3480
+ if (!matchesPattern(pattern, event.category)) continue;
3481
+ for (const entry of set) {
3482
+ if (!matchesEventFilter(event, entry.filter)) continue;
3483
+ try {
3484
+ entry.handler(event);
3485
+ } catch (err) {}
3486
+ }
3487
+ }
3488
+ }
3489
+ //#endregion
3363
3490
  //#region src/kernel/moleculer/resilient-cap-call.ts
3364
3491
  /** Moleculer error `type` values meaning "the service is not (yet) routable". */
3365
3492
  var DISCOVERY_ERROR_TYPES = new Set(["SERVICE_NOT_FOUND", "SERVICE_NOT_AVAILABLE"]);
@@ -3754,6 +3881,11 @@ var CapRouteResolver = class {
3754
3881
  };
3755
3882
  //#endregion
3756
3883
  //#region src/kernel/transport/local-child-registry.ts
3884
+ function readFanoutMode() {
3885
+ const raw = process.env.CAMSTACK_UDS_EVENT_FANOUT;
3886
+ if (raw === "shadow" || raw === "broadcast") return raw;
3887
+ return "filter";
3888
+ }
3757
3889
  /**
3758
3890
  * Sentinel prefix used in the no-route error thrown when `cap-call-out` has no
3759
3891
  * local sibling and no `onUnownedCall` fallback. `ipcParentLink` detects this
@@ -3778,8 +3910,15 @@ var LocalChildRegistry = class {
3778
3910
  onUnownedCall;
3779
3911
  logger;
3780
3912
  getActiveSingletonAddonId;
3913
+ resolveChildIdForAddon;
3781
3914
  /** Tracks capNames already logged as UDS-routed; one INFO line per capName per process. */
3782
3915
  egressRoutedCaps = /* @__PURE__ */ new Set();
3916
+ /** Active event fan-out mode, read once from `CAMSTACK_UDS_EVENT_FANOUT`. */
3917
+ fanoutMode = readFanoutMode();
3918
+ /** Per-child event fan-out counters (sent / suppressed). */
3919
+ childEventStats = /* @__PURE__ */ new Map();
3920
+ /** Last pattern-set string logged per child, to dedup the INFO line. */
3921
+ loggedPatternSet = /* @__PURE__ */ new Map();
3783
3922
  /**
3784
3923
  * Accepts either a plain positional `server` argument (backward-compatible)
3785
3924
  * or a full `LocalChildRegistryOptions` object.
@@ -3798,16 +3937,68 @@ var LocalChildRegistry = class {
3798
3937
  this.onUnownedCall = opts.onUnownedCall;
3799
3938
  this.logger = opts.logger;
3800
3939
  this.getActiveSingletonAddonId = opts.getActiveSingletonAddonId;
3940
+ this.resolveChildIdForAddon = opts.resolveChildIdForAddon;
3801
3941
  } else {
3802
3942
  this.server = serverOrOptions;
3803
3943
  this.onUnownedCall = onUnownedCallArg;
3804
3944
  }
3805
3945
  }
3806
3946
  async start() {
3947
+ this.logger?.info("UDS event fan-out mode", { mode: this.fanoutMode });
3807
3948
  this.server.onConnection((channel) => this.onConnection(channel));
3808
3949
  await this.server.listen();
3809
3950
  }
3810
3951
  /**
3952
+ * Per-child event fan-out counters (`{ sent, suppressed }`) or `null` if the
3953
+ * child has no recorded events yet. Exposed for the starvation-verification
3954
+ * surface (shadow burn-in + operator debug).
3955
+ */
3956
+ getChildEventStats(childId) {
3957
+ const s = this.childEventStats.get(childId);
3958
+ return s === void 0 ? null : {
3959
+ sent: s.sent,
3960
+ suppressed: s.suppressed
3961
+ };
3962
+ }
3963
+ /**
3964
+ * Does `entry` want to receive an event in `category`? Honours the fan-out
3965
+ * mode: `broadcast` → always; undeclared patterns (`null`) → always
3966
+ * (fail-open); else the child's declared set must match via `matchesPattern`
3967
+ * — the SAME function the child's own `deliverShared` map-key gate uses,
3968
+ * giving provable delivery parity. `shadow` is handled by the caller (it
3969
+ * counts the would-suppress but still sends).
3970
+ *
3971
+ * NOTE: this gate must NOT consult `RING_BUFFER_DENY_PATTERNS` /
3972
+ * `isHighFrequencyCategory` — those are ring-buffer STORAGE policy; a child
3973
+ * that explicitly subscribes to a ring-denied category must still RECEIVE it.
3974
+ */
3975
+ childWantsEvent(entry, category) {
3976
+ if (this.fanoutMode === "broadcast") return true;
3977
+ if (entry.eventPatterns === null) return true;
3978
+ return entry.eventPatterns.some((p) => matchesPattern(p, category));
3979
+ }
3980
+ statsFor(childId) {
3981
+ let s = this.childEventStats.get(childId);
3982
+ if (s === void 0) {
3983
+ s = {
3984
+ sent: 0,
3985
+ suppressed: 0
3986
+ };
3987
+ this.childEventStats.set(childId, s);
3988
+ }
3989
+ return s;
3990
+ }
3991
+ /** Deduped INFO line whenever a child's declared pattern set changes. */
3992
+ logPatternSet(childId, patterns) {
3993
+ const key = patterns === null ? "<undeclared>" : JSON.stringify([...patterns].toSorted());
3994
+ if (this.loggedPatternSet.get(childId) === key) return;
3995
+ this.loggedPatternSet.set(childId, key);
3996
+ this.logger?.info("child event patterns", {
3997
+ childId,
3998
+ patterns: patterns ?? null
3999
+ });
4000
+ }
4001
+ /**
3811
4002
  * Child id that can service a call to `capName` (optionally addressing
3812
4003
  * `deviceId`), or null.
3813
4004
  *
@@ -3831,8 +4022,11 @@ var LocalChildRegistry = class {
3831
4022
  const candidates = this.findAllChildIds((cap) => cap.capName === capName && cap.deviceId === void 0);
3832
4023
  if (candidates.length === 0) return null;
3833
4024
  if (candidates.length === 1) return candidates[0];
3834
- const preferred = this.getActiveSingletonAddonId?.(capName) ?? null;
3835
- if (preferred !== null && candidates.includes(preferred)) return preferred;
4025
+ const preferredAddonId = this.getActiveSingletonAddonId?.(capName) ?? null;
4026
+ if (preferredAddonId !== null) {
4027
+ const preferredChildId = this.resolveChildIdForAddon?.(preferredAddonId) ?? preferredAddonId;
4028
+ if (candidates.includes(preferredChildId)) return preferredChildId;
4029
+ }
3836
4030
  return candidates[0];
3837
4031
  }
3838
4032
  /** First child whose cap manifest contains a descriptor matching `predicate`. */
@@ -3983,14 +4177,20 @@ var LocalChildRegistry = class {
3983
4177
  * one (the originating child, to avoid echo). Fire-and-forget.
3984
4178
  */
3985
4179
  broadcastEventToChildren(event, sourceNodeId, exceptChildId) {
4180
+ const msg = {
4181
+ kind: "event",
4182
+ event,
4183
+ sourceNodeId
4184
+ };
3986
4185
  for (const entry of this.children.values()) {
3987
4186
  if (entry.childId === exceptChildId) continue;
3988
- const msg = {
3989
- kind: "event",
3990
- event,
3991
- sourceNodeId
3992
- };
3993
- entry.channel.emit(msg);
4187
+ const wants = this.childWantsEvent(entry, event.category);
4188
+ const stats = this.statsFor(entry.childId);
4189
+ if (!wants) stats.suppressed++;
4190
+ if (wants || this.fanoutMode === "shadow") {
4191
+ stats.sent++;
4192
+ entry.channel.emit(msg);
4193
+ }
3994
4194
  }
3995
4195
  }
3996
4196
  /**
@@ -4026,17 +4226,31 @@ var LocalChildRegistry = class {
4026
4226
  if (childId !== null) this.logHandler?.(childId, msg);
4027
4227
  return;
4028
4228
  }
4229
+ if (msg.kind === "event-sub") {
4230
+ if (childId === null) return;
4231
+ const entry = this.children.get(childId);
4232
+ if (entry === void 0) return;
4233
+ this.children.set(childId, {
4234
+ ...entry,
4235
+ eventPatterns: msg.patterns
4236
+ });
4237
+ this.logPatternSet(childId, msg.patterns);
4238
+ return;
4239
+ }
4029
4240
  });
4030
4241
  channel.onRequest(async (body) => {
4031
4242
  const msg = body;
4032
4243
  if (msg.kind === "register") {
4033
4244
  if (childId !== null && childId !== msg.childId) throw new Error(`child attempted to change identity from "${childId}" to "${msg.childId}"`);
4034
4245
  childId = msg.childId;
4246
+ const eventPatterns = msg.eventPatterns ?? null;
4035
4247
  this.children.set(msg.childId, {
4036
4248
  childId: msg.childId,
4037
4249
  channel,
4038
- caps: msg.caps
4250
+ caps: msg.caps,
4251
+ eventPatterns
4039
4252
  });
4253
+ this.logPatternSet(msg.childId, eventPatterns);
4040
4254
  this.registeredHandler({
4041
4255
  childId: msg.childId,
4042
4256
  caps: msg.caps
@@ -4069,7 +4283,11 @@ var LocalChildRegistry = class {
4069
4283
  throw new Error(`unknown child request kind: ${msg.kind}`);
4070
4284
  });
4071
4285
  channel.onClose(() => {
4072
- if (childId !== null && this.children.delete(childId)) this.goneHandler(childId);
4286
+ if (childId !== null && this.children.delete(childId)) {
4287
+ this.childEventStats.delete(childId);
4288
+ this.loggedPatternSet.delete(childId);
4289
+ this.goneHandler(childId);
4290
+ }
4073
4291
  });
4074
4292
  }
4075
4293
  };
@@ -4101,6 +4319,24 @@ var LocalChildClient = class {
4101
4319
  * the latest set) instead of being lost or throwing.
4102
4320
  */
4103
4321
  latestCaps;
4322
+ /**
4323
+ * Per-owner (addonId) category-pattern subscription sets, and their union.
4324
+ * The union is declared to the parent (in `RegisterMessage.eventPatterns`
4325
+ * pre/at-connect, or via an `event-sub` emit post-connect) so the parent can
4326
+ * subscription-filter its event fan-out. Per-owner keying keeps the union
4327
+ * correct if multiple event buses ever share one client (group-runner case).
4328
+ */
4329
+ patternsByOwner = /* @__PURE__ */ new Map();
4330
+ latestEventPatterns = [];
4331
+ /**
4332
+ * Whether `updateEventPatterns` has ever been called. A client that never
4333
+ * declared a subscription set (no event bus wired — e.g. a pure cap-call
4334
+ * runner) omits `eventPatterns` from its register frame entirely, so the
4335
+ * parent treats it as UNDECLARED and fails OPEN (broadcast-all). Once any
4336
+ * owner declares (in production every addon context's framework
4337
+ * subscriptions do), the register carries the real union — even if empty.
4338
+ */
4339
+ hasDeclaredEventPatterns = false;
4104
4340
  /** Events and logs queued while the channel is not yet open. */
4105
4341
  pendingEmits = [];
4106
4342
  /** Handler for parent→child events. Registered via `onEvent`. */
@@ -4127,6 +4363,42 @@ var LocalChildClient = class {
4127
4363
  this.latestCaps = options.caps;
4128
4364
  }
4129
4365
  /**
4366
+ * Declare an owner's live category-pattern subscription set. Stores it
4367
+ * per-owner (keyed by `ownerId` = addonId), recomputes the union, and — only
4368
+ * if the union changed — declares it to the parent:
4369
+ * - pre-connect: buffered only; `start()`'s register frame carries the set.
4370
+ * - post-connect: emitted as a fire-and-forget `event-sub` (full-set
4371
+ * replace).
4372
+ * Idempotent on an unchanged union (no redundant `event-sub` frames).
4373
+ */
4374
+ updateEventPatterns(ownerId, patterns) {
4375
+ this.hasDeclaredEventPatterns = true;
4376
+ this.patternsByOwner.set(ownerId, patterns);
4377
+ const union = this.computePatternUnion();
4378
+ if (!this.patternsEqual(union, this.latestEventPatterns)) {
4379
+ this.latestEventPatterns = union;
4380
+ if (this.channel !== null) {
4381
+ const msg = {
4382
+ kind: "event-sub",
4383
+ patterns: union
4384
+ };
4385
+ this.channel.emit(msg);
4386
+ }
4387
+ }
4388
+ }
4389
+ /** Sorted, de-duplicated union across all owners' pattern sets. */
4390
+ computePatternUnion() {
4391
+ const all = /* @__PURE__ */ new Set();
4392
+ for (const patterns of this.patternsByOwner.values()) for (const p of patterns) all.add(p);
4393
+ return [...all].toSorted();
4394
+ }
4395
+ /** Order-insensitive equality — both inputs are already sorted unions here. */
4396
+ patternsEqual(a, b) {
4397
+ if (a.length !== b.length) return false;
4398
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
4399
+ return true;
4400
+ }
4401
+ /**
4130
4402
  * Register a callback that fires each time the client successfully connects
4131
4403
  * (or reconnects) to its parent. Multiple handlers may be registered; all
4132
4404
  * are called in registration order. Used by readiness-context in UDS mode
@@ -4205,7 +4477,8 @@ var LocalChildClient = class {
4205
4477
  const register = {
4206
4478
  kind: "register",
4207
4479
  childId: this.options.childId,
4208
- caps: this.latestCaps
4480
+ caps: this.latestCaps,
4481
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4209
4482
  };
4210
4483
  try {
4211
4484
  await channel.request(register);
@@ -4238,7 +4511,8 @@ var LocalChildClient = class {
4238
4511
  const register = {
4239
4512
  kind: "register",
4240
4513
  childId: this.options.childId,
4241
- caps
4514
+ caps,
4515
+ ...this.hasDeclaredEventPatterns ? { eventPatterns: this.latestEventPatterns } : {}
4242
4516
  };
4243
4517
  await this.channel.request(register);
4244
4518
  }
@@ -4399,105 +4673,6 @@ function udsChildLogToWorkerEntry(childId, entry) {
4399
4673
  };
4400
4674
  }
4401
4675
  //#endregion
4402
- //#region src/kernel/moleculer/event-bus-core.ts
4403
- function createSharedBusState() {
4404
- return {
4405
- handlers: /* @__PURE__ */ new Map(),
4406
- recent: []
4407
- };
4408
- }
4409
- function matchesPattern(pattern, category) {
4410
- if (pattern === "*" || pattern === "**") return true;
4411
- if (pattern.endsWith(".**")) return category.startsWith(pattern.slice(0, -3));
4412
- if (pattern.endsWith(".*")) return category.startsWith(pattern.slice(0, -2));
4413
- return pattern === category;
4414
- }
4415
- function extractCategoryPattern(filter) {
4416
- if (typeof filter === "string") return filter;
4417
- if (filter && typeof filter === "object" && "category" in filter) {
4418
- const cat = filter.category;
4419
- if (typeof cat === "string") return cat;
4420
- if (Array.isArray(cat) && cat.length > 0) return String(cat[0]);
4421
- }
4422
- return "*";
4423
- }
4424
- /**
4425
- * Match an event against the full `EventFilter`. The Map-keyed subscribe path
4426
- * only handles the category pattern (deciding which handler set to fan out to);
4427
- * this function applies the remaining scope dimensions (`agentId`, `addonId`,
4428
- * `deviceId`, `source`, `since`) so per-device / per-addon subscribers don't
4429
- * receive sibling events.
4430
- */
4431
- function matchesEventFilter(event, filter) {
4432
- if (!filter || typeof filter === "string") return true;
4433
- if (filter.source) {
4434
- if (event.source.type !== filter.source.type || event.source.id !== filter.source.id) return false;
4435
- }
4436
- if (filter.agentId) {
4437
- const eventNodeId = event.source.nodeId;
4438
- if (!eventNodeId || eventNodeId !== filter.agentId && !eventNodeId.startsWith(`${filter.agentId}/`)) return false;
4439
- }
4440
- if (filter.addonId) {
4441
- if ((event.source.addonId ?? (event.source.type === "addon" ? String(event.source.id) : void 0)) !== filter.addonId) return false;
4442
- }
4443
- if (filter.deviceId !== void 0) {
4444
- if ((event.source.deviceId ?? (event.source.type === "device" ? Number(event.source.id) : void 0)) !== filter.deviceId) return false;
4445
- }
4446
- if (filter.since && event.timestamp < filter.since) return false;
4447
- return true;
4448
- }
4449
- /**
4450
- * Categories filtered out of the `recent[]` ring buffer.
4451
- *
4452
- * High-frequency, low-value-for-the-operator categories — periodic metrics
4453
- * snapshots, per-frame inference traces, raw motion-mask dumps, model-download
4454
- * progress, etc. They still fan out to live subscribers (UI charts that
4455
- * explicitly listen for them), but do NOT enter the recent-events ring so
4456
- * meaningful events (motion / phase transitions / device lifecycle / addon
4457
- * lifecycle / recording / detection.event) survive in the last-N window without
4458
- * being displaced by per-frame noise.
4459
- */
4460
- var RING_BUFFER_DENY_PATTERNS = [
4461
- "pipeline.camera-metrics-snapshot",
4462
- "pipeline.runner-load-snapshot",
4463
- "pipeline.engine-metrics-snapshot",
4464
- "pipeline.inference-result",
4465
- "pipeline.trace",
4466
- "pipeline.progress",
4467
- "stream-broker.metrics-snapshot",
4468
- "metrics.node-resources-snapshot",
4469
- "metrics.node-processes-snapshot",
4470
- "cluster.topology-snapshot",
4471
- "detection.motion-analysis",
4472
- "detection.motion-zones-raw",
4473
- "detection.result",
4474
- "pipeline.audio-inference-result",
4475
- "platform-probe.phase",
4476
- "benchmark.progress",
4477
- "model.download.progress",
4478
- "capability.binding-changed"
4479
- ];
4480
- function isHighFrequencyCategory(category) {
4481
- for (const pattern of RING_BUFFER_DENY_PATTERNS) if (matchesPattern(pattern, category)) return true;
4482
- return false;
4483
- }
4484
- /**
4485
- * Deliver `event` to all matching local subscribers and — unless it is
4486
- * high-frequency — append it to the `recent[]` ring.
4487
- */
4488
- function deliverShared(state, event) {
4489
- if (!isHighFrequencyCategory(event.category)) state.recent.push(event);
4490
- for (const [pattern, set] of state.handlers) {
4491
- if (!matchesPattern(pattern, event.category)) continue;
4492
- for (const entry of set) {
4493
- if (!matchesEventFilter(event, entry.filter)) continue;
4494
- try {
4495
- entry.handler(event);
4496
- } catch (err) {}
4497
- }
4498
- }
4499
- }
4500
- //#endregion
4501
4676
  //#region src/kernel/transport/uds-event-bus.ts
4502
4677
  /**
4503
4678
  * Create a UDS-backed `IEventBus` for use inside a forked child process.
@@ -4512,6 +4687,9 @@ function createUdsEventBus(client, addonId) {
4512
4687
  client.onEvent((event) => {
4513
4688
  deliverShared(state, event);
4514
4689
  });
4690
+ const reportPatterns = () => {
4691
+ client.updateEventPatterns(addonId, [...state.handlers.keys()]);
4692
+ };
4515
4693
  return {
4516
4694
  emit(event) {
4517
4695
  const enriched = {
@@ -4531,8 +4709,13 @@ function createUdsEventBus(client, addonId) {
4531
4709
  const set = state.handlers.get(pattern) ?? /* @__PURE__ */ new Set();
4532
4710
  set.add(entry);
4533
4711
  state.handlers.set(pattern, set);
4712
+ reportPatterns();
4534
4713
  return () => {
4535
- state.handlers.get(pattern)?.delete(entry);
4714
+ const current = state.handlers.get(pattern);
4715
+ if (current === void 0) return;
4716
+ current.delete(entry);
4717
+ if (current.size === 0) state.handlers.delete(pattern);
4718
+ reportPatterns();
4536
4719
  };
4537
4720
  },
4538
4721
  getRecent(filter, limit) {
@@ -5660,12 +5843,20 @@ function clusterEventTopic(category) {
5660
5843
  * brokers GC naturally.
5661
5844
  */
5662
5845
  var brokerBusState = /* @__PURE__ */ new WeakMap();
5663
- function getSharedBusState(broker) {
5846
+ /**
5847
+ * @param retainRecent - `true` from the hub-main opt-in call site
5848
+ * (`event-bus.service.ts`). If the bus was already created by a non-retaining
5849
+ * caller (e.g. `process-service`, the `$event-bus` handler, or the per-addon
5850
+ * wrapper) we UPGRADE it in place so the opt-in wins regardless of call
5851
+ * order. Only the hub main process ever passes `true`; agents + child runners
5852
+ * never do, so their bus stays one-shot.
5853
+ */
5854
+ function getSharedBusState(broker, retainRecent = false) {
5664
5855
  let state = brokerBusState.get(broker);
5665
5856
  if (!state) {
5666
- state = createSharedBusState();
5857
+ state = createSharedBusState(retainRecent);
5667
5858
  brokerBusState.set(broker, state);
5668
- }
5859
+ } else if (retainRecent && !state.retainRecent) state.retainRecent = true;
5669
5860
  return state;
5670
5861
  }
5671
5862
  /** Brokers that already have the `$event-bus` service installed. */
@@ -5712,9 +5903,16 @@ function registerEventBusService(broker) {
5712
5903
  } catch {}
5713
5904
  }
5714
5905
  }
5715
- function getBrokerEventBus(broker) {
5906
+ /**
5907
+ * @param options.retainRecent - Pass `true` ONLY from the hub main process
5908
+ * (`EventBusService.attachBroker`), the single process that serves the
5909
+ * `getRecent`/audit capability. Every other caller (agents, child runners,
5910
+ * the per-addon wrapper, the `$event-bus` handler) omits it → the bus retains
5911
+ * nothing and `getRecent` returns `[]`. Events are one-shot everywhere else.
5912
+ */
5913
+ function getBrokerEventBus(broker, options) {
5716
5914
  const bkr = broker;
5717
- const state = getSharedBusState(broker);
5915
+ const state = getSharedBusState(broker, options?.retainRecent ?? false);
5718
5916
  registerEventBusService(broker);
5719
5917
  const warnTimestamps = (() => {
5720
5918
  let m = brokerBusWarnTimestamps.get(broker);