@camstack/system 1.1.22 → 1.1.24

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.
@@ -1,5 +1,5 @@
1
1
  import { canonicalDeviceFingerprint } from "@camstack/types/node";
2
- import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateSchemaFields, errMsg, getByPath, isDeviceConfigCap, parseStreamParamsFormPatch, setByPath, sleep } from "@camstack/types";
2
+ import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, evaluateLinkExpression, getByPath, isDeviceConfigCap, normalizeUnit, parseStreamParamsFormPatch, setByPath, sleep, toExpressionValue, validateExpressionSource } from "@camstack/types";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z } from "zod";
5
5
  /**
@@ -295,7 +295,8 @@ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
295
295
  ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
296
296
  ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
297
297
  ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
298
- ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {}
298
+ ...metaRow?.deviceLinks !== void 0 ? { deviceLinks: metaRow.deviceLinks } : {},
299
+ ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
299
300
  };
300
301
  }
301
302
  function resolveDeviceById(registry, deviceId) {
@@ -504,6 +505,21 @@ async function getBindings(deps, input) {
504
505
  });
505
506
  seenCaps.add(entry.capName);
506
507
  }
508
+ if (deps.devicesWithLinks?.has(input.deviceId)) {
509
+ const row = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(input.deviceId)];
510
+ for (const link of row?.deviceLinks ?? []) {
511
+ const capName = link.target.cap;
512
+ if (seenCaps.has(capName)) continue;
513
+ entries.push({
514
+ capName,
515
+ kind: "linked",
516
+ providerAddonId: deps.ctx.id,
517
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
518
+ nativeAddonId: ""
519
+ });
520
+ seenCaps.add(capName);
521
+ }
522
+ }
507
523
  return {
508
524
  deviceId: input.deviceId,
509
525
  entries
@@ -1166,11 +1182,47 @@ async function setWrapperActive(deps, input) {
1166
1182
  });
1167
1183
  }
1168
1184
  /**
1185
+ * Build the wireable-cap entry for one capability definition: the status
1186
+ * schema's scalar leaf fields plus — for an item-array cap
1187
+ * (`status.itemArray`) — the per-item fields tagged `item: true` (a link
1188
+ * targeting one must carry a `target.itemKey`) and the cap-level `itemArray`
1189
+ * descriptor so the UI can address items. Returns null when the cap exposes
1190
+ * nothing wireable (no status schema / no leaf fields).
1191
+ */
1192
+ function wireableEntryForDef(capName, def) {
1193
+ const status = def.status;
1194
+ const schema = status?.schema;
1195
+ if (!schema) return null;
1196
+ const itemArray = status.itemArray;
1197
+ const fields = [...enumerateSchemaFields(schema), ...itemArray ? enumerateItemArrayFields(itemArray) : []].map((f) => ({
1198
+ path: f.path,
1199
+ kind: f.kind,
1200
+ ...f.enumValues !== void 0 ? { enumValues: [...f.enumValues] } : {},
1201
+ ...f.item === true ? { item: true } : {}
1202
+ }));
1203
+ if (fields.length === 0) return null;
1204
+ return {
1205
+ cap: capName,
1206
+ fields,
1207
+ ...itemArray ? { itemArray: {
1208
+ path: itemArray.path,
1209
+ keyField: itemArray.keyField
1210
+ } } : {}
1211
+ };
1212
+ }
1213
+ /**
1169
1214
  * Per-device wireable-field catalog — the domain status fields a device's
1170
1215
  * bound caps expose, for the operator's cross-device wiring UI. Binding-driven:
1171
1216
  * walks `getBindings(deviceId)`, skips wrapper caps (their status schemas are
1172
1217
  * internal book-keeping, not wireable domain data), and enumerates each cap's
1173
- * status schema leaf fields. Behavior unchanged from the inline provider method.
1218
+ * status schema leaf fields (per-item fields included for item-array caps).
1219
+ *
1220
+ * `includeSynthesizable: true` (TARGET pickers only) additionally unions in
1221
+ * UNBOUND device-scoped caps that declare `status.empty` (the synthesize-target
1222
+ * marker) and whose `deviceTypes` admit this device's persisted type — so the
1223
+ * FIRST link to a synthesize-only cap (consumables on an HA vacuum) can be
1224
+ * authored before any binding exists. Default (absent/false) behavior is
1225
+ * byte-identical to the binding-driven catalog.
1174
1226
  */
1175
1227
  async function getWireableFields(deps, input) {
1176
1228
  const { deviceId } = input;
@@ -1184,20 +1236,20 @@ async function getWireableFields(deps, input) {
1184
1236
  seen.add(entry.capName);
1185
1237
  const def = reg.getDefinition(entry.capName);
1186
1238
  if (!def || def.kind === "wrapper") continue;
1187
- const schema = def.status?.schema;
1188
- if (!schema) continue;
1189
- const fields = enumerateSchemaFields(schema).map((f) => f.enumValues !== void 0 ? {
1190
- path: f.path,
1191
- kind: f.kind,
1192
- enumValues: [...f.enumValues]
1193
- } : {
1194
- path: f.path,
1195
- kind: f.kind
1196
- });
1197
- if (fields.length > 0) caps.push({
1198
- cap: entry.capName,
1199
- fields
1200
- });
1239
+ const wireable = wireableEntryForDef(entry.capName, def);
1240
+ if (wireable) caps.push(wireable);
1241
+ }
1242
+ if (input.includeSynthesizable === true) {
1243
+ const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
1244
+ if (deviceType !== void 0) for (const def of ALL_CAPABILITY_DEFINITIONS) {
1245
+ if (seen.has(def.name)) continue;
1246
+ if (def.scope !== "device" || def.kind === "wrapper") continue;
1247
+ if (def.status?.empty === void 0) continue;
1248
+ if (def.deviceTypes && !def.deviceTypes.some((t) => t === deviceType)) continue;
1249
+ seen.add(def.name);
1250
+ const wireable = wireableEntryForDef(def.name, def);
1251
+ if (wireable) caps.push(wireable);
1252
+ }
1201
1253
  }
1202
1254
  return { caps };
1203
1255
  }
@@ -1311,6 +1363,120 @@ var DeviceEventPropagator = class {
1311
1363
  }
1312
1364
  };
1313
1365
  //#endregion
1366
+ //#region src/builtins/device-manager/device-link-cycle.ts
1367
+ function nodeKey(deviceId, cap) {
1368
+ return `${deviceId}:${cap}`;
1369
+ }
1370
+ /** The target's container stableId — its parent's (falling back to its own for
1371
+ * a top-level target). Sibling FIELD sources resolve relative to this, the same
1372
+ * rule `rebuildLinkDependents` / `resolveLinkedStatus` apply. */
1373
+ function containerStableId(targetRow, rowById) {
1374
+ return targetRow.parentDeviceId !== null ? rowById.get(targetRow.parentDeviceId)?.stableId ?? targetRow.stableId : targetRow.stableId;
1375
+ }
1376
+ /** Resolve ONE field/global/literal binding to its `(deviceId, cap)` node, or
1377
+ * null when it is a literal or resolves to no known device. */
1378
+ function resolveBindingNode(binding, targetRow, rowById, idByStableId) {
1379
+ if (binding.kind === "literal") return null;
1380
+ const wantedStableId = binding.kind === "global" ? binding.sourceStableId : `${containerStableId(targetRow, rowById)}-${binding.sourceKey}`;
1381
+ const srcId = idByStableId.get(wantedStableId);
1382
+ if (srcId === void 0) return null;
1383
+ return nodeKey(srcId, binding.cap);
1384
+ }
1385
+ /**
1386
+ * Resolve a link source to the set of `(deviceId, cap)` nodes it depends on.
1387
+ * A literal source depends on nothing; a field/global source depends on exactly
1388
+ * one node; an EXPRESSION source depends on one node per resolvable non-literal
1389
+ * binding. Unresolvable (dangling) bindings contribute no edge — they cannot
1390
+ * loop while dangling; a cycle that only becomes resolvable later is bounded by
1391
+ * the resolve-time re-entrancy guard.
1392
+ */
1393
+ function resolveSourceNodes(link, targetRow, rowById, idByStableId) {
1394
+ const src = link.source;
1395
+ if (src.kind === "expression") {
1396
+ const nodes = [];
1397
+ for (const binding of Object.values(src.bindings)) {
1398
+ const node = resolveBindingNode(binding, targetRow, rowById, idByStableId);
1399
+ if (node !== null) nodes.push(node);
1400
+ }
1401
+ return nodes;
1402
+ }
1403
+ const node = resolveBindingNode(src, targetRow, rowById, idByStableId);
1404
+ return node !== null ? [node] : [];
1405
+ }
1406
+ /**
1407
+ * Detect whether replacing `editedDeviceId`'s links with `editedLinks` closes
1408
+ * a dependency cycle. Returns the cycle as an ordered list of node keys
1409
+ * (first node repeated at the end) or null when the set is safe.
1410
+ */
1411
+ function findDeviceLinkCycle(allMeta, editedDeviceId, editedLinks) {
1412
+ const rows = Object.values(allMeta);
1413
+ const rowById = /* @__PURE__ */ new Map();
1414
+ const idByStableId = /* @__PURE__ */ new Map();
1415
+ for (const r of rows) {
1416
+ rowById.set(r.id, r);
1417
+ if (!idByStableId.has(r.stableId)) idByStableId.set(r.stableId, r.id);
1418
+ }
1419
+ const dependsOn = /* @__PURE__ */ new Map();
1420
+ const addEdges = (row, links) => {
1421
+ for (const link of links) {
1422
+ const srcNodes = resolveSourceNodes(link, row, rowById, idByStableId);
1423
+ if (srcNodes.length === 0) continue;
1424
+ const tNode = nodeKey(row.id, link.target.cap);
1425
+ const list = dependsOn.get(tNode) ?? [];
1426
+ for (const srcNode of srcNodes) list.push(srcNode);
1427
+ dependsOn.set(tNode, list);
1428
+ }
1429
+ };
1430
+ for (const r of rows) {
1431
+ const links = r.id === editedDeviceId ? editedLinks : r.deviceLinks ?? [];
1432
+ if (links.length > 0) addEdges(r, links);
1433
+ }
1434
+ const roots = [...new Set(editedLinks.map((l) => nodeKey(editedDeviceId, l.target.cap)))];
1435
+ for (const root of roots) {
1436
+ const cycle = dfsFindCycle(root, dependsOn);
1437
+ if (cycle) return cycle;
1438
+ }
1439
+ return null;
1440
+ }
1441
+ /** Iterative DFS from `root` over `dependsOn`; returns the first back-edge
1442
+ * cycle path (closed — first node repeated last) or null. */
1443
+ function dfsFindCycle(root, dependsOn) {
1444
+ const onPath = /* @__PURE__ */ new Set();
1445
+ const done = /* @__PURE__ */ new Set();
1446
+ const path = [];
1447
+ const stack = [{
1448
+ node: root,
1449
+ nextChild: 0
1450
+ }];
1451
+ onPath.add(root);
1452
+ path.push(root);
1453
+ while (stack.length > 0) {
1454
+ const frame = stack[stack.length - 1];
1455
+ const children = dependsOn.get(frame.node) ?? [];
1456
+ if (frame.nextChild < children.length) {
1457
+ const child = children[frame.nextChild];
1458
+ frame.nextChild += 1;
1459
+ if (onPath.has(child)) {
1460
+ const start = path.indexOf(child);
1461
+ return [...path.slice(start), child];
1462
+ }
1463
+ if (done.has(child)) continue;
1464
+ stack.push({
1465
+ node: child,
1466
+ nextChild: 0
1467
+ });
1468
+ onPath.add(child);
1469
+ path.push(child);
1470
+ continue;
1471
+ }
1472
+ stack.pop();
1473
+ onPath.delete(frame.node);
1474
+ path.pop();
1475
+ done.add(frame.node);
1476
+ }
1477
+ return null;
1478
+ }
1479
+ //#endregion
1314
1480
  //#region src/builtins/device-manager/device-meta-actions.ts
1315
1481
  /**
1316
1482
  * Device meta-mutation + persistence actions for the device-manager addon —
@@ -1324,7 +1490,7 @@ var DeviceEventPropagator = class {
1324
1490
  * removeDevice), config persistence (persistConfig, loadConfig), the meta
1325
1491
  * surface load (loadMeta, loadRuntimeState), every meta setter (setName,
1326
1492
  * setLocation, setType, setIntegrationId, setLinkDeviceId,
1327
- * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole,
1493
+ * setPrimaryChildEntityId, setChildLayout, setDeviceLinks, setRole, setDisplay,
1328
1494
  * applyInitialMeta, setMetadata, setDisabled), and the location registry
1329
1495
  * (listLocations, addLocation, removeLocation).
1330
1496
  *
@@ -1433,6 +1599,7 @@ async function registerDevice(pctx, input) {
1433
1599
  ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
1434
1600
  ...existingMeta?.deviceLinks !== void 0 ? { deviceLinks: existingMeta.deviceLinks } : {},
1435
1601
  ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
1602
+ ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
1436
1603
  parentDeviceId,
1437
1604
  id,
1438
1605
  features: featuresArr,
@@ -1872,6 +2039,13 @@ async function setChildLayout(pctx, input) {
1872
2039
  */
1873
2040
  async function setDeviceLinks(pctx, input) {
1874
2041
  const { deviceId, deviceLinks } = input;
2042
+ const cycle = findDeviceLinkCycle(await pctx.metaStore.readMeta(), deviceId, deviceLinks);
2043
+ if (cycle) throw new Error(`[device-manager] setDeviceLinks: cross-device link cycle: ${cycle.join(" → ")}`);
2044
+ for (const link of deviceLinks) {
2045
+ if (link.source.kind !== "expression") continue;
2046
+ const err = validateExpressionSource(link.source);
2047
+ if (err) throw new Error(`[device-manager] setDeviceLinks: invalid expression on link '${link.id}': ${err}`);
2048
+ }
1875
2049
  await pctx.metaStore.withMetaWriteLock(async () => {
1876
2050
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1877
2051
  if (!persisted) throw new Error(`[device-manager] setDeviceLinks: unknown device id=${deviceId}`);
@@ -1942,6 +2116,84 @@ async function setRole(pctx, input) {
1942
2116
  });
1943
2117
  }
1944
2118
  /**
2119
+ * Normalize an operator-authored display override at write time so the render
2120
+ * path's `UNIT_TABLE` lookups always hit canonical spellings — maps `unit` and
2121
+ * every `perCap[*].unit` through `normalizeUnit`. Pure: rebuilds new objects,
2122
+ * never mutates the input. A unit `normalizeUnit` cannot canonicalize is left
2123
+ * as-is (the render path refuses conversion for unknown spellings).
2124
+ */
2125
+ function normalizeDisplayOverride(display) {
2126
+ return {
2127
+ ...display,
2128
+ ...display.unit !== void 0 ? { unit: normalizeUnit(display.unit) ?? display.unit } : {},
2129
+ ...display.perCap !== void 0 ? { perCap: Object.fromEntries(Object.entries(display.perCap).map(([cap, refine]) => [cap, refine.unit !== void 0 ? {
2130
+ ...refine,
2131
+ unit: normalizeUnit(refine.unit) ?? refine.unit
2132
+ } : refine])) } : {}
2133
+ };
2134
+ }
2135
+ /**
2136
+ * Set (or clear) the per-device display override on a device's meta row.
2137
+ * Mirrors `setChildLayout` persistence; `null` REMOVES the `display` key
2138
+ * entirely (immutable rest-destructure — matches the "absent ⇒ no override"
2139
+ * projection contract, never persists `display: undefined`). Override units are
2140
+ * normalized at write so the render path always looks up canonical spellings.
2141
+ */
2142
+ async function setDisplay(pctx, input) {
2143
+ const { deviceId, display } = input;
2144
+ const normalized = display === null ? null : normalizeDisplayOverride(display);
2145
+ await pctx.metaStore.withMetaWriteLock(async () => {
2146
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
2147
+ if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
2148
+ const { meta: m } = persisted;
2149
+ const key = String(deviceId);
2150
+ const allMeta = await pctx.metaStore.readMeta();
2151
+ const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
2152
+ ...m,
2153
+ display: normalized
2154
+ };
2155
+ await pctx.settings.writeAddonStore({ deviceMeta: {
2156
+ ...allMeta,
2157
+ [key]: nextRow
2158
+ } });
2159
+ });
2160
+ pctx.host.ctx.eventBus.emit({
2161
+ id: randomUUID(),
2162
+ timestamp: /* @__PURE__ */ new Date(),
2163
+ source: {
2164
+ type: "device",
2165
+ id: deviceId
2166
+ },
2167
+ category: EventCategory.DeviceMetaChanged,
2168
+ data: {
2169
+ deviceId,
2170
+ field: "display",
2171
+ value: normalized
2172
+ }
2173
+ });
2174
+ }
2175
+ /**
2176
+ * Read the operator-authored per-role display defaults. Empty record when none
2177
+ * set. Not per-device — a plain top-level-key read.
2178
+ */
2179
+ async function getRoleDisplayDefaults(pctx, _input) {
2180
+ return { defaults: (await pctx.metaStore.readStore()).roleDisplayDefaults ?? {} };
2181
+ }
2182
+ /**
2183
+ * Replace the per-role display defaults whole-record (full replace). Override
2184
+ * units are normalized (`normalizeUnit`) at write so the render path always
2185
+ * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
2186
+ * no interaction with the `deviceMeta` write lock. Not per-device, so no event
2187
+ * is emitted; the UI invalidates its own query on mutate.
2188
+ */
2189
+ async function setRoleDisplayDefaults(pctx, input) {
2190
+ const normalized = Object.fromEntries(Object.entries(input.defaults).map(([role, def]) => [role, def.unit !== void 0 ? {
2191
+ ...def,
2192
+ unit: normalizeUnit(def.unit) ?? def.unit
2193
+ } : def]));
2194
+ await pctx.settings.writeAddonStore({ roleDisplayDefaults: normalized });
2195
+ }
2196
+ /**
1945
2197
  * Batched meta pre-seed. Applies every provided field to the
1946
2198
  * device's meta row in ONE read-modify-write under a single
1947
2199
  * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
@@ -2186,21 +2438,33 @@ async function removeLocation(pctx, input) {
2186
2438
  function buildLinkIndexes(entries) {
2187
2439
  const targets = /* @__PURE__ */ new Map();
2188
2440
  const dependents = /* @__PURE__ */ new Map();
2189
- for (const { targetDeviceId, link, sourceDeviceId } of entries) {
2441
+ for (const { targetDeviceId, link, sourceDeviceId, bindingSourceIds } of entries) {
2190
2442
  const tKey = `${targetDeviceId}:${link.target.cap}`;
2191
2443
  const tList = targets.get(tKey) ?? [];
2192
2444
  tList.push({
2193
2445
  link,
2194
- sourceDeviceId
2446
+ sourceDeviceId,
2447
+ ...bindingSourceIds !== void 0 ? { bindingSourceIds } : {}
2195
2448
  });
2196
2449
  targets.set(tKey, tList);
2197
- const sKey = `${sourceDeviceId}:${link.source.cap}`;
2198
- const sList = dependents.get(sKey) ?? [];
2199
- sList.push({
2200
- targetDeviceId,
2201
- targetCap: link.target.cap
2202
- });
2203
- dependents.set(sKey, sList);
2450
+ const addDependent = (sourceId, sourceCap) => {
2451
+ const sKey = `${sourceId}:${sourceCap}`;
2452
+ const sList = dependents.get(sKey) ?? [];
2453
+ sList.push({
2454
+ targetDeviceId,
2455
+ targetCap: link.target.cap
2456
+ });
2457
+ dependents.set(sKey, sList);
2458
+ };
2459
+ if (link.source.kind === "expression") {
2460
+ if (bindingSourceIds !== void 0) for (const [name, bindingDeviceId] of Object.entries(bindingSourceIds)) {
2461
+ const binding = link.source.bindings[name];
2462
+ if (binding === void 0 || binding.kind === "literal") continue;
2463
+ addDependent(bindingDeviceId, binding.cap);
2464
+ }
2465
+ continue;
2466
+ }
2467
+ if (link.source.kind !== "literal") addDependent(sourceDeviceId, link.source.cap);
2204
2468
  }
2205
2469
  return {
2206
2470
  targets,
@@ -2315,6 +2579,15 @@ var DeviceMetaStore = class {
2315
2579
  for (const m of Object.values(meta)) if (m.stableId === wanted) return m.id;
2316
2580
  return null;
2317
2581
  };
2582
+ /** Resolve a GLOBAL link source (P2e) to a live device id: the device whose
2583
+ * FULL stableId equals `sourceStableId`, regardless of parent container.
2584
+ * First match wins (stableIds are effectively unique cluster-wide — see the
2585
+ * `DeviceLinkGlobalSource` docblock). Null when absent. Pure over a
2586
+ * pre-read meta map so a multi-link resolve reads the store once. */
2587
+ resolveGlobalSourceDeviceId = (sourceStableId, meta) => {
2588
+ for (const m of Object.values(meta)) if (m.stableId === sourceStableId) return m.id;
2589
+ return null;
2590
+ };
2318
2591
  allocateNextDeviceId = async () => {
2319
2592
  const current = (await this.readStore()).nextDeviceId ?? 1;
2320
2593
  await this.settings.writeAddonStore({ nextDeviceId: current + 1 });
@@ -2345,8 +2618,35 @@ var DeviceMetaStore = class {
2345
2618
  const container = targetMeta.parentDeviceId !== null ? stableIdById.get(targetMeta.parentDeviceId) ?? stableIdById.get(targetId) : stableIdById.get(targetId);
2346
2619
  if (container === void 0) continue;
2347
2620
  for (const link of targetMeta.deviceLinks ?? []) {
2348
- expectedSources.add(`${container}-${link.source.sourceKey}`);
2349
- const srcId = idByStableId.get(`${container}-${link.source.sourceKey}`);
2621
+ const src = link.source;
2622
+ if (src.kind === "expression") {
2623
+ const bindingSourceIds = {};
2624
+ for (const [name, b] of Object.entries(src.bindings)) {
2625
+ if (b.kind === "literal") continue;
2626
+ const wanted = b.kind === "global" ? b.sourceStableId : `${container}-${b.sourceKey}`;
2627
+ expectedSources.add(wanted);
2628
+ const bid = idByStableId.get(wanted);
2629
+ if (bid !== void 0) bindingSourceIds[name] = bid;
2630
+ }
2631
+ entries.push({
2632
+ targetDeviceId: targetId,
2633
+ link,
2634
+ sourceDeviceId: -1,
2635
+ bindingSourceIds
2636
+ });
2637
+ continue;
2638
+ }
2639
+ if (src.kind === "literal") {
2640
+ entries.push({
2641
+ targetDeviceId: targetId,
2642
+ link,
2643
+ sourceDeviceId: -1
2644
+ });
2645
+ continue;
2646
+ }
2647
+ const wantedStableId = src.kind === "global" ? src.sourceStableId : `${container}-${src.sourceKey}`;
2648
+ expectedSources.add(wantedStableId);
2649
+ const srcId = idByStableId.get(wantedStableId);
2350
2650
  if (srcId === void 0) continue;
2351
2651
  entries.push({
2352
2652
  targetDeviceId: targetId,
@@ -2453,6 +2753,7 @@ async function listAll(pctx, input) {
2453
2753
  ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2454
2754
  ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2455
2755
  ...m?.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2756
+ ...m?.display !== void 0 ? { display: m.display } : {},
2456
2757
  ...(() => {
2457
2758
  const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
2458
2759
  return si !== void 0 ? { sourceInfo: si } : {};
@@ -2501,6 +2802,7 @@ async function getDevice(pctx, input) {
2501
2802
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2502
2803
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2503
2804
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2805
+ ...m.display !== void 0 ? { display: m.display } : {},
2504
2806
  ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
2505
2807
  };
2506
2808
  }
@@ -2563,6 +2865,7 @@ async function getChildren(pctx, input) {
2563
2865
  ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
2564
2866
  ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
2565
2867
  ...m.deviceLinks !== void 0 ? { deviceLinks: m.deviceLinks } : {},
2868
+ ...m.display !== void 0 ? { display: m.display } : {},
2566
2869
  ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
2567
2870
  });
2568
2871
  }
@@ -2892,6 +3195,48 @@ async function testField(pctx, input) {
2892
3195
  function isRecord(x) {
2893
3196
  return x !== null && typeof x === "object" && !Array.isArray(x);
2894
3197
  }
3198
+ /**
3199
+ * Resolve one link's transformed value. Field sources read via `readField`
3200
+ * (the caller supplies the sibling lookup — sync, e.g. the in-hub state
3201
+ * mirror); literal sources use their per-device constant and never consult
3202
+ * the reader. Returns `undefined` to skip the overlay (unreadable field).
3203
+ * Pure. Async source reads (`resolveLinkedStatus`'s provider `getStatus`
3204
+ * path) cannot use this helper for field sources — they branch on
3205
+ * `source.kind` directly.
3206
+ */
3207
+ function resolveLinkValue(link, readField) {
3208
+ const src = link.source;
3209
+ if (src.kind === "expression") return void 0;
3210
+ return applyTransform(src.kind === "literal" ? src.value : readField(src.cap, src.fieldPath), link.transform);
3211
+ }
3212
+ /**
3213
+ * Sync-channel expression resolution: bindings read from the mirror via
3214
+ * `readField(deviceId, cap, fieldPath)` using the pre-resolved per-binding ids
3215
+ * on the `ResolvedTargetLink`. A literal binding uses its constant; an absent
3216
+ * binding id (dangling source) or a non-primitive read resolves to `null`.
3217
+ * Returns `undefined` on parse/eval failure (skip overlay) — silent by design:
3218
+ * the mirror path is hot and churn-free; the async channel logs. Compile
3219
+ * failures cost one parse ever thanks to the negative LRU compile cache. Pure.
3220
+ */
3221
+ function resolveExpressionLinkValue(rl, readField, now) {
3222
+ const src = rl.link.source;
3223
+ if (src.kind !== "expression") return void 0;
3224
+ const bindingValues = {};
3225
+ for (const [name, b] of Object.entries(src.bindings)) {
3226
+ if (b.kind === "literal") {
3227
+ bindingValues[name] = b.value;
3228
+ continue;
3229
+ }
3230
+ const bId = rl.bindingSourceIds?.[name];
3231
+ if (bId === void 0) {
3232
+ bindingValues[name] = null;
3233
+ continue;
3234
+ }
3235
+ bindingValues[name] = toExpressionValue(readField(bId, b.cap, b.fieldPath)) ?? null;
3236
+ }
3237
+ const result = evaluateLinkExpression(src.expr, bindingValues, now);
3238
+ return result.ok ? applyTransform(result.value, rl.link.transform) : void 0;
3239
+ }
2895
3240
  /** Narrow Zod v4's structural `$ZodType` (returned by `.unwrap()`) back to the
2896
3241
  * concrete classic `z.ZodType`. Every runtime schema is a `z.ZodType`, so this
2897
3242
  * is a true `instanceof` guard rather than a cast. */
@@ -2923,19 +3268,88 @@ function fillNullableDefaults(schema, value) {
2923
3268
  return out;
2924
3269
  }
2925
3270
  /**
3271
+ * Group item-targeted links (`target.itemKey` set) by itemKey and upsert each
3272
+ * group as ONE item into the status' item array (see
3273
+ * `CapabilityStatusItemArray`). A NEW item is seeded from `emptyItem` with
3274
+ * `keyField` (and `labelField`, when declared) set to the itemKey; an
3275
+ * EXISTING item (matched by `keyField`) is cloned and overlaid in place, so
3276
+ * native fields a link doesn't touch are preserved. Each upserted item is
3277
+ * validated against `itemSchema` — an invalid item is skipped WITHOUT
3278
+ * discarding its valid siblings. Returns the new draft and whether anything
3279
+ * was applied. Pure + immutable (base array/items are never mutated).
3280
+ */
3281
+ function upsertItemArrayLinks(draft, grouped, itemArray) {
3282
+ const arrRaw = getByPath(draft, itemArray.path);
3283
+ const arr = Array.isArray(arrRaw) ? [...arrRaw] : [];
3284
+ let touched = false;
3285
+ for (const [itemKey, group] of grouped) {
3286
+ const idx = arr.findIndex((el) => isRecord(el) && el[itemArray.keyField] === itemKey);
3287
+ const existing = idx >= 0 ? arr[idx] : null;
3288
+ let item = isRecord(existing) ? { ...existing } : {
3289
+ ...itemArray.emptyItem,
3290
+ [itemArray.keyField]: itemKey,
3291
+ ...itemArray.labelField !== void 0 ? { [itemArray.labelField]: itemKey } : {}
3292
+ };
3293
+ let itemTouched = false;
3294
+ for (const { link, sourceValue } of group) {
3295
+ if (sourceValue === void 0) continue;
3296
+ item = setByPath(item, link.target.fieldPath, sourceValue);
3297
+ itemTouched = true;
3298
+ }
3299
+ if (!itemTouched) continue;
3300
+ const repaired = fillNullableDefaults(itemArray.itemSchema, item);
3301
+ const parsed = itemArray.itemSchema.safeParse(repaired);
3302
+ if (!parsed.success) continue;
3303
+ if (isRecord(parsed.data)) item = parsed.data;
3304
+ if (idx >= 0) arr[idx] = item;
3305
+ else arr.push(item);
3306
+ touched = true;
3307
+ }
3308
+ if (!touched) return {
3309
+ draft,
3310
+ touched: false
3311
+ };
3312
+ return {
3313
+ draft: setByPath(draft, itemArray.path, arr),
3314
+ touched: true
3315
+ };
3316
+ }
3317
+ /**
2926
3318
  * Overlay transformed source values onto `base` by dot-path, then validate the
2927
3319
  * result against the target cap's `statusSchema`. On validation failure the
2928
3320
  * overlay is discarded and `base` is returned unchanged (a misconfigured link
2929
3321
  * must never corrupt a cap response). Pure — all I/O happens in the caller.
3322
+ *
3323
+ * Item-array grouping (P2b): when the cap declares `status.itemArray`, links
3324
+ * carrying a `target.itemKey` are grouped per key and upserted as items into
3325
+ * the array (see `upsertItemArrayLinks`); their `fieldPath` is relative to
3326
+ * ONE item ('level', 'status', 'label'). Links WITHOUT an itemKey keep the
3327
+ * scalar dot-path behavior unchanged. A link with an itemKey on a cap that
3328
+ * declares NO `itemArray` is dropped (its item-relative path must never be
3329
+ * scalar-applied to the status root).
2930
3330
  */
2931
- function mergeLinkedStatus(base, resolved, statusSchema) {
3331
+ function mergeLinkedStatus(base, resolved, statusSchema, itemArray) {
2932
3332
  let draft = base;
2933
3333
  let touched = false;
2934
- for (const { link, sourceValue } of resolved) {
2935
- if (sourceValue === void 0) continue;
2936
- draft = setByPath(draft, link.target.fieldPath, sourceValue);
3334
+ const grouped = /* @__PURE__ */ new Map();
3335
+ for (const entry of resolved) {
3336
+ const itemKey = entry.link.target.itemKey;
3337
+ if (itemKey !== void 0) {
3338
+ if (!itemArray) continue;
3339
+ const group = grouped.get(itemKey);
3340
+ if (group) group.push(entry);
3341
+ else grouped.set(itemKey, [entry]);
3342
+ continue;
3343
+ }
3344
+ if (entry.sourceValue === void 0) continue;
3345
+ draft = setByPath(draft, entry.link.target.fieldPath, entry.sourceValue);
2937
3346
  touched = true;
2938
3347
  }
3348
+ if (itemArray && grouped.size > 0) {
3349
+ const upserted = upsertItemArrayLinks(draft, grouped, itemArray);
3350
+ draft = upserted.draft;
3351
+ touched = touched || upserted.touched;
3352
+ }
2939
3353
  if (!touched) return base;
2940
3354
  if (!statusSchema) return draft;
2941
3355
  const repaired = fillNullableDefaults(statusSchema, draft);
@@ -3132,43 +3546,63 @@ var DeviceStateMirror = class DeviceStateMirror {
3132
3546
  * Read-time overlay of a cap slice with its cross-device linked values.
3133
3547
  * Returns a cloned raw mirror slice when the (device, cap) pair has no
3134
3548
  * links. Sources are read from the same in-hub stateMirror — sync, no
3135
- * cross-process call. The disk writer must NOT use this method; it must
3136
- * persist raw provider truth via snapshotForDevice.
3549
+ * cross-process call. When the raw slice is ABSENT but the cap is a link
3550
+ * target, the slice is SYNTHESIZED: the merge base is seeded from the
3551
+ * cap's declared `status.empty` default; a failed/empty synthesize
3552
+ * returns null (no phantom slices). The disk writer must NOT use this
3553
+ * method; it must persist raw provider truth via snapshotForDevice.
3137
3554
  */
3138
3555
  overlayedSlice(deviceId, cap) {
3139
3556
  const raw = this.stateMirror.get(deviceId)?.get(cap) ?? null;
3140
3557
  const links = this.linkHost.linkTargets.get(`${deviceId}:${cap}`);
3141
3558
  if (!links || links.length === 0) return raw ? { ...raw } : null;
3559
+ const now = Date.now();
3142
3560
  const resolved = links.map((rl) => ({
3143
3561
  link: rl.link,
3144
- sourceValue: applyTransform(getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(rl.link.source.cap), rl.link.source.fieldPath), rl.link.transform)
3562
+ sourceValue: rl.link.source.kind === "expression" ? resolveExpressionLinkValue(rl, (srcDeviceId, srcCap, fieldPath) => getByPath(this.stateMirror.get(srcDeviceId)?.get(srcCap), fieldPath), now) : resolveLinkValue(rl.link, (srcCap, fieldPath) => getByPath(this.stateMirror.get(rl.sourceDeviceId)?.get(srcCap), fieldPath))
3145
3563
  }));
3146
- const schema = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status?.schema;
3147
- return mergeLinkedStatus(raw ? { ...raw } : {}, resolved, schema);
3564
+ const capStatus = this.linkHost.capabilityRegistry?.getDefinition(cap)?.status;
3565
+ const schema = capStatus?.schema;
3566
+ const empty = capStatus?.empty;
3567
+ const base = raw ? { ...raw } : empty !== null && typeof empty === "object" && !Array.isArray(empty) ? { ...empty } : {};
3568
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus?.itemArray);
3569
+ if (!raw && merged === base) return null;
3570
+ return merged;
3148
3571
  }
3149
3572
  /**
3150
3573
  * Like snapshotForDevice but applies the device-link overlay per cap.
3151
3574
  * Used exclusively by the device-state READ methods (getSnapshot,
3152
- * getAllSnapshots) so callers see overlayed values. The debounced disk
3153
- * writer must continue to call snapshotForDevice (raw truth).
3575
+ * getAllSnapshots) so callers see overlayed values. SYNTHESIZE-only
3576
+ * link-target caps (no base mirror slice) are unioned into the iterated
3577
+ * key set so they appear at warm-load — `overlayedSlice` builds them from
3578
+ * the cap's `status.empty` (and drops them when nothing resolves). The
3579
+ * debounced disk writer must continue to call snapshotForDevice (raw truth).
3154
3580
  */
3155
3581
  snapshotForDeviceOverlayed(deviceId) {
3156
3582
  const perCap = this.stateMirror.get(deviceId);
3157
- if (!perCap) return {};
3583
+ const capNames = new Set(perCap ? perCap.keys() : []);
3584
+ const prefix = `${deviceId}:`;
3585
+ for (const key of this.linkHost.linkTargets.keys()) if (key.startsWith(prefix)) capNames.add(key.slice(prefix.length));
3158
3586
  const out = {};
3159
- for (const capName of perCap.keys()) {
3587
+ for (const capName of capNames) {
3160
3588
  const s = this.overlayedSlice(deviceId, capName);
3161
3589
  if (s) out[capName] = s;
3162
3590
  }
3163
3591
  return out;
3164
3592
  }
3165
- /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`. */
3593
+ /** Whole-system mirror dump (overlaid). Backs `deviceState.getAllSnapshots`.
3594
+ * Devices that only exist as link TARGETS (no mirror slice yet) are
3595
+ * included via the link reverse-index so their synthesized caps appear. */
3166
3596
  allSnapshotsOverlayed() {
3597
+ const deviceIds = new Set(this.stateMirror.keys());
3598
+ for (const key of this.linkHost.linkTargets.keys()) {
3599
+ const id = Number(key.slice(0, key.indexOf(":")));
3600
+ if (Number.isFinite(id)) deviceIds.add(id);
3601
+ }
3167
3602
  const out = {};
3168
- for (const [deviceId, perCap] of this.stateMirror) {
3169
- const dev = {};
3170
- for (const [capName, slice] of perCap) dev[capName] = this.overlayedSlice(deviceId, capName) ?? { ...slice };
3171
- out[String(deviceId)] = dev;
3603
+ for (const deviceId of deviceIds) {
3604
+ const dev = this.snapshotForDeviceOverlayed(deviceId);
3605
+ if (this.stateMirror.has(deviceId) || Object.keys(dev).length > 0) out[String(deviceId)] = dev;
3172
3606
  }
3173
3607
  return out;
3174
3608
  }
@@ -3287,14 +3721,27 @@ function resolveNativeCapOwnerSync(pctx, capName, deviceId) {
3287
3721
  * `getProviderForDevice` (routes cross-process); merge is pure.
3288
3722
  */
3289
3723
  async function resolveLinkedStatus(pctx, input) {
3290
- const { deviceId, cap, baseStatus } = input;
3724
+ const { deviceId, cap } = input;
3291
3725
  if (!pctx.host.devicesWithLinks.has(deviceId)) return null;
3726
+ const inFlightKey = `${deviceId}:${cap}`;
3727
+ if (pctx.host.linkResolveInFlight.has(inFlightKey)) return null;
3728
+ pctx.host.linkResolveInFlight.add(inFlightKey);
3729
+ try {
3730
+ return await resolveLinkedStatusInner(pctx, input);
3731
+ } finally {
3732
+ pctx.host.linkResolveInFlight.delete(inFlightKey);
3733
+ }
3734
+ }
3735
+ /** Body of `resolveLinkedStatus` — see the wrapper for the re-entrancy guard. */
3736
+ async function resolveLinkedStatusInner(pctx, input) {
3737
+ const { deviceId, cap, baseStatus } = input;
3292
3738
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3293
3739
  if (!persisted) return null;
3294
3740
  const links = (persisted.meta.deviceLinks ?? []).filter((l) => l.target.cap === cap);
3295
3741
  if (links.length === 0) return null;
3296
3742
  const capRegistry = pctx.host.capabilityRegistry;
3297
- const schema = capRegistry?.getDefinition(cap)?.status?.schema;
3743
+ const capStatus = capRegistry?.getDefinition(cap)?.status;
3744
+ const schema = capStatus?.schema;
3298
3745
  if (!schema) return null;
3299
3746
  const allMeta = await pctx.metaStore.readMeta();
3300
3747
  let containerStableId = persisted.stableId;
@@ -3303,32 +3750,96 @@ async function resolveLinkedStatus(pctx, input) {
3303
3750
  const parentMeta = allMeta[String(parentId)];
3304
3751
  if (parentMeta) containerStableId = parentMeta.stableId;
3305
3752
  }
3753
+ const now = Date.now();
3754
+ const statusMemo = /* @__PURE__ */ new Map();
3755
+ const readSourceStatus = async (srcId, srcCap) => {
3756
+ const key = `${srcId}:${srcCap}`;
3757
+ const cached = statusMemo.get(key);
3758
+ if (cached !== void 0) return cached;
3759
+ let result;
3760
+ try {
3761
+ const srcProvider = capRegistry?.getProviderForDevice(srcCap, srcId);
3762
+ result = {
3763
+ status: typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0,
3764
+ ok: true
3765
+ };
3766
+ } catch (err) {
3767
+ pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
3768
+ tags: {
3769
+ deviceId,
3770
+ capName: cap
3771
+ },
3772
+ meta: {
3773
+ sourceCap: srcCap,
3774
+ sourceId: srcId,
3775
+ error: err instanceof Error ? err.message : String(err)
3776
+ }
3777
+ });
3778
+ result = {
3779
+ status: void 0,
3780
+ ok: false
3781
+ };
3782
+ }
3783
+ statusMemo.set(key, result);
3784
+ return result;
3785
+ };
3306
3786
  const resolved = [];
3307
3787
  for (const link of links) {
3308
- const srcId = pctx.metaStore.resolveSourceDeviceId(containerStableId, link.source.sourceKey, allMeta);
3309
- if (srcId === null) continue;
3310
- try {
3311
- const srcProvider = capRegistry?.getProviderForDevice(link.source.cap, srcId);
3312
- const raw = getByPath(typeof srcProvider?.getStatus === "function" ? await srcProvider.getStatus({ deviceId: srcId }) : void 0, link.source.fieldPath);
3788
+ const src = link.source;
3789
+ if (src.kind === "literal") {
3313
3790
  resolved.push({
3314
3791
  link,
3315
- sourceValue: applyTransform(raw, link.transform)
3792
+ sourceValue: applyTransform(src.value, link.transform)
3316
3793
  });
3317
- } catch (err) {
3318
- pctx.host.ctx.logger.warn("resolveLinkedStatus: source read failed", {
3794
+ continue;
3795
+ }
3796
+ if (src.kind === "expression") {
3797
+ const bindingValues = {};
3798
+ for (const [name, b] of Object.entries(src.bindings)) {
3799
+ if (b.kind === "literal") {
3800
+ bindingValues[name] = b.value;
3801
+ continue;
3802
+ }
3803
+ const bId = b.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(b.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, b.sourceKey, allMeta);
3804
+ if (bId === null) {
3805
+ bindingValues[name] = null;
3806
+ continue;
3807
+ }
3808
+ bindingValues[name] = toExpressionValue(getByPath((await readSourceStatus(bId, b.cap)).status, b.fieldPath)) ?? null;
3809
+ }
3810
+ const result = evaluateLinkExpression(src.expr, bindingValues, now);
3811
+ if (result.ok) resolved.push({
3812
+ link,
3813
+ sourceValue: applyTransform(result.value, link.transform)
3814
+ });
3815
+ else pctx.host.ctx.logger.warn("resolveLinkedStatus: expression skipped", {
3319
3816
  tags: {
3320
3817
  deviceId,
3321
3818
  capName: cap
3322
3819
  },
3323
3820
  meta: {
3324
- sourceKey: link.source.sourceKey,
3325
- error: err instanceof Error ? err.message : String(err)
3821
+ linkId: link.id,
3822
+ error: result.error
3326
3823
  }
3327
3824
  });
3825
+ continue;
3328
3826
  }
3827
+ const srcId = src.kind === "global" ? pctx.metaStore.resolveGlobalSourceDeviceId(src.sourceStableId, allMeta) : pctx.metaStore.resolveSourceDeviceId(containerStableId, src.sourceKey, allMeta);
3828
+ if (srcId === null) continue;
3829
+ const read = await readSourceStatus(srcId, src.cap);
3830
+ if (!read.ok) continue;
3831
+ const raw = getByPath(read.status, src.fieldPath);
3832
+ resolved.push({
3833
+ link,
3834
+ sourceValue: applyTransform(raw, link.transform)
3835
+ });
3329
3836
  }
3330
3837
  if (resolved.length === 0) return null;
3331
- return mergeLinkedStatus(isRecord$1(baseStatus) ? baseStatus : {}, resolved, schema);
3838
+ const synthesizing = !isRecord$1(baseStatus);
3839
+ const base = isRecord$1(baseStatus) ? baseStatus : isRecord$1(capStatus.empty) ? { ...capStatus.empty } : {};
3840
+ const merged = mergeLinkedStatus(base, resolved, schema, capStatus.itemArray);
3841
+ if (synthesizing && merged === base) return null;
3842
+ return merged;
3332
3843
  }
3333
3844
  //#endregion
3334
3845
  //#region src/builtins/device-manager/device-manager.addon.ts
@@ -3394,10 +3905,15 @@ var DeviceManagerAddon = class extends BaseAddon {
3394
3905
  linkTargets = /* @__PURE__ */ new Map();
3395
3906
  /** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
3396
3907
  linkDependents = /* @__PURE__ */ new Map();
3397
- /** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
3908
+ /** Expected source `stableId`s (`${container}-${sourceKey}` for sibling
3909
+ * sources, the full `sourceStableId` for global sources) across all links,
3398
3910
  * resolved or not — gates the `registerDevice` rebuild so only a registering
3399
3911
  * device that IS a link source triggers a reindex (not every boot restore). */
3400
3912
  expectedSourceStableIds = /* @__PURE__ */ new Set();
3913
+ /** In-flight `${deviceId}:${cap}` pairs of `resolveLinkedStatus` — the P2e
3914
+ * defensive re-entrancy guard bounding link cycles at resolve time. Mutated
3915
+ * in place (never reassigned), so the host exposes a direct reference. */
3916
+ linkResolveInFlight = /* @__PURE__ */ new Set();
3401
3917
  /** Test/diagnostic accessors. */
3402
3918
  linkTargetKeys() {
3403
3919
  return [...this.linkTargets.keys()];
@@ -3469,6 +3985,7 @@ var DeviceManagerAddon = class extends BaseAddon {
3469
3985
  get expectedSourceStableIds() {
3470
3986
  return expectedSourceStableIds();
3471
3987
  },
3988
+ linkResolveInFlight: this.linkResolveInFlight,
3472
3989
  dropDeviceOverlays: (deviceId) => this.stateMirror.dropDeviceOverlays(deviceId),
3473
3990
  remoteNativeCaps: this.remoteNativeCaps,
3474
3991
  seedMirror: (deviceId, blob) => this.stateMirror.seedMirror(deviceId, blob),
@@ -3517,12 +4034,15 @@ var DeviceManagerAddon = class extends BaseAddon {
3517
4034
  }
3518
4035
  }))).filter((id) => id !== null);
3519
4036
  }
3520
- /** Build the dependency context the extracted binding resolvers consume. */
4037
+ /** Build the dependency context the extracted binding resolvers consume.
4038
+ * `devicesWithLinks` is mutated in place (never reassigned) so the direct
4039
+ * reference stays live — it gates the virtual `linked` binding step. */
3521
4040
  get bindingsDeps() {
3522
4041
  return {
3523
4042
  ctx: this.ctx,
3524
4043
  capabilityRegistry: this.capabilityRegistry,
3525
- remoteNativeCaps: this.remoteNativeCaps
4044
+ remoteNativeCaps: this.remoteNativeCaps,
4045
+ devicesWithLinks: this.devicesWithLinks
3526
4046
  };
3527
4047
  }
3528
4048
  async getBindings(input) {
@@ -3625,6 +4145,9 @@ var DeviceManagerAddon = class extends BaseAddon {
3625
4145
  setChildLayout: (input) => setChildLayout(pctx, input),
3626
4146
  setDeviceLinks: (input) => setDeviceLinks(pctx, input),
3627
4147
  setRole: (input) => setRole(pctx, input),
4148
+ setDisplay: (input) => setDisplay(pctx, input),
4149
+ getRoleDisplayDefaults: (input) => getRoleDisplayDefaults(pctx, input),
4150
+ setRoleDisplayDefaults: (input) => setRoleDisplayDefaults(pctx, input),
3628
4151
  applyInitialMeta: (input) => applyInitialMeta(pctx, input),
3629
4152
  setMetadata: (input) => setMetadata(pctx, input),
3630
4153
  setDisabled: (input) => setDisabled(pctx, input),