@camstack/addon-terminal 0.1.12 → 0.1.13

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.
package/dist/addon.js CHANGED
@@ -18428,24 +18428,28 @@ var snapshotCapability = {
18428
18428
  *
18429
18429
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
18430
18430
  * the wrapper happens to hold and never captures. Under D93 the client
18431
- * versions its image URL on that answer, and an image REQUEST is what enrols
18432
- * a camera in the keep-warm loop. Both of those are satisfiable by the
18433
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
18434
- * — so a URL painted in a previous session comes off disk with no network,
18435
- * no enrolment, and nothing warming. Measured on the live hub: reopening
18436
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
18437
- * HTTP requests, and the fleet only recovered because a later poll happened
18438
- * to observe a different identity.
18431
+ * versions its image URL on that answer, and an image REQUEST was the only
18432
+ * demand signal. Both of those are satisfiable by the client's own image
18433
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
18434
+ * in a previous session comes off disk with no network, no demand, and no
18435
+ * capture. Measured on the live hub: reopening after two minutes idle
18436
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
18437
+ * fleet only recovered because a later poll happened to observe a different
18438
+ * identity.
18439
18439
  *
18440
18440
  * ## The two properties that fix it
18441
18441
  *
18442
18442
  * **It is an RPC, so no client cache can answer it.** The demand signal
18443
- * always reaches the wrapper. This method therefore MAY create keep-warm
18444
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
18445
- * distinction is not "one is newer" but that the overview poll is app-wide
18446
- * (a creating overview would warm every camera on the install) while this is
18447
- * called by a rendered surface naming the tiles it is actually painting, at
18448
- * the width it is painting them.
18443
+ * always reaches the wrapper. This method therefore CAPTURES, where
18444
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
18445
+ * newer" but that the overview poll is app-wide (a capturing overview would
18446
+ * dial every camera on the install) while this is called by a rendered
18447
+ * surface naming the tiles it is actually painting, at the width it is
18448
+ * painting them.
18449
+ *
18450
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
18451
+ * server-side keep-warm loop was removed (operator directive — on-demand,
18452
+ * always), so a camera nobody is looking at costs nothing at all.
18449
18453
  *
18450
18454
  * **It waits, briefly and boundedly, for the capture it triggered.** The
18451
18455
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27414,9 +27418,10 @@ var DeclaredDevices = class {
27414
27418
  }
27415
27419
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27416
27420
  const index = await this.readIndex();
27421
+ const live = await this.readLiveByStableId();
27417
27422
  const outcomes = [];
27418
27423
  for (const declaration of spec.devices) {
27419
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27424
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27420
27425
  if (outcome !== null) outcomes.push(outcome);
27421
27426
  }
27422
27427
  return {
@@ -27462,6 +27467,26 @@ var DeclaredDevices = class {
27462
27467
  return new Map(rows.map((row) => [row.stableId, row]));
27463
27468
  }
27464
27469
  /**
27470
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27471
+ *
27472
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27473
+ * is persisted rows, this is live objects. A row without an object must be
27474
+ * adopted; an object must be left exactly as it is.
27475
+ *
27476
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27477
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27478
+ * genuinely needs bringing up.
27479
+ */
27480
+ async readLiveByStableId() {
27481
+ try {
27482
+ const devices = await this.ports.devices.getAll();
27483
+ return new Map(devices.map((device) => [device.stableId, device]));
27484
+ } catch (err) {
27485
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27486
+ return /* @__PURE__ */ new Map();
27487
+ }
27488
+ }
27489
+ /**
27465
27490
  * One declaration: adopt what exists, create what does not.
27466
27491
  *
27467
27492
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27470,8 +27495,15 @@ var DeclaredDevices = class {
27470
27495
  * the declared name over the operator's rename. D49: that branch needs a
27471
27496
  * second read to agree.
27472
27497
  */
27473
- async applyDeclaration(declaration, integrationId, index) {
27498
+ async applyDeclaration(declaration, integrationId, index, live) {
27474
27499
  try {
27500
+ const alreadyLive = live.get(declaration.stableId);
27501
+ if (alreadyLive !== void 0) return {
27502
+ stableId: declaration.stableId,
27503
+ deviceId: alreadyLive.id,
27504
+ device: alreadyLive,
27505
+ created: false
27506
+ };
27475
27507
  let existing = index.get(declaration.stableId);
27476
27508
  if (existing === void 0) {
27477
27509
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -33478,9 +33510,20 @@ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33478
33510
  function escapeXml(value) {
33479
33511
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33480
33512
  }
33481
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33513
+ /**
33514
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
33515
+ *
33516
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
33517
+ * runs of whitespace by default, and a terminal's entire column alignment IS
33518
+ * runs of whitespace — Glances pads every field with spaces. Without it the
33519
+ * frame drew each line at roughly half its true width, crammed into the
33520
+ * top-left of a mostly-black image, while the SAME session over `attach`
33521
+ * looked perfect — which is exactly how the operator reported it. Measured in
33522
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33523
+ * collapsed against 178 px preserved.
33524
+ */
33482
33525
  async function renderTerminalJpeg(lines) {
33483
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33526
+ const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33484
33527
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
33485
33528
  return (0, sharp.default)(Buffer.from(svg)).jpeg({
33486
33529
  quality: 82,
@@ -39608,12 +39651,46 @@ var TerminalAddon = class extends BaseAddon {
39608
39651
  this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39609
39652
  }
39610
39653
  }
39654
+ if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
39611
39655
  const nodeId = outcome.device.config.get("nodeId");
39612
39656
  const profileId = outcome.device.config.get("profileId");
39613
39657
  const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39614
39658
  outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
39615
39659
  }
39616
39660
  }
39661
+ /**
39662
+ * A Terminal camera is a rendered screen. Object detection on it finds
39663
+ * nothing, forever, at full cost.
39664
+ *
39665
+ * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
39666
+ * through the detection pipeline at ~61 ms of inference each, plus 115
39667
+ * capture-scheduler requests a minute — against `detections=0`. Multiply by
39668
+ * one terminal per node and it is a standing tax on a hub that was already
39669
+ * shedding 86 % of its capture queue.
39670
+ *
39671
+ * Written through `setCameraSwitch`, which is the authority that already owns
39672
+ * this function — [D62] forbids a second store that disagrees with it. And
39673
+ * written ONLY on creation: an operator who deliberately turns detection back
39674
+ * on for a terminal must win, and a reconcile that re-asserted every pass
39675
+ * would silently overrule them once a minute.
39676
+ */
39677
+ async silenceAnalysisFor(deviceId) {
39678
+ for (const switchId of ["object-detection", "audio-analysis"]) try {
39679
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
39680
+ deviceId,
39681
+ switchId,
39682
+ enabled: false
39683
+ });
39684
+ } catch (err) {
39685
+ this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
39686
+ tags: { deviceId },
39687
+ meta: {
39688
+ switchId,
39689
+ error: err instanceof Error ? err.message : String(err)
39690
+ }
39691
+ });
39692
+ }
39693
+ }
39617
39694
  terminalInstanceControl() {
39618
39695
  return {
39619
39696
  listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
package/dist/addon.mjs CHANGED
@@ -18406,24 +18406,28 @@ var snapshotCapability = {
18406
18406
  *
18407
18407
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
18408
18408
  * the wrapper happens to hold and never captures. Under D93 the client
18409
- * versions its image URL on that answer, and an image REQUEST is what enrols
18410
- * a camera in the keep-warm loop. Both of those are satisfiable by the
18411
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
18412
- * — so a URL painted in a previous session comes off disk with no network,
18413
- * no enrolment, and nothing warming. Measured on the live hub: reopening
18414
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
18415
- * HTTP requests, and the fleet only recovered because a later poll happened
18416
- * to observe a different identity.
18409
+ * versions its image URL on that answer, and an image REQUEST was the only
18410
+ * demand signal. Both of those are satisfiable by the client's own image
18411
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
18412
+ * in a previous session comes off disk with no network, no demand, and no
18413
+ * capture. Measured on the live hub: reopening after two minutes idle
18414
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
18415
+ * fleet only recovered because a later poll happened to observe a different
18416
+ * identity.
18417
18417
  *
18418
18418
  * ## The two properties that fix it
18419
18419
  *
18420
18420
  * **It is an RPC, so no client cache can answer it.** The demand signal
18421
- * always reaches the wrapper. This method therefore MAY create keep-warm
18422
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
18423
- * distinction is not "one is newer" but that the overview poll is app-wide
18424
- * (a creating overview would warm every camera on the install) while this is
18425
- * called by a rendered surface naming the tiles it is actually painting, at
18426
- * the width it is painting them.
18421
+ * always reaches the wrapper. This method therefore CAPTURES, where
18422
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
18423
+ * newer" but that the overview poll is app-wide (a capturing overview would
18424
+ * dial every camera on the install) while this is called by a rendered
18425
+ * surface naming the tiles it is actually painting, at the width it is
18426
+ * painting them.
18427
+ *
18428
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
18429
+ * server-side keep-warm loop was removed (operator directive — on-demand,
18430
+ * always), so a camera nobody is looking at costs nothing at all.
18427
18431
  *
18428
18432
  * **It waits, briefly and boundedly, for the capture it triggered.** The
18429
18433
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27392,9 +27396,10 @@ var DeclaredDevices = class {
27392
27396
  }
27393
27397
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27394
27398
  const index = await this.readIndex();
27399
+ const live = await this.readLiveByStableId();
27395
27400
  const outcomes = [];
27396
27401
  for (const declaration of spec.devices) {
27397
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27402
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27398
27403
  if (outcome !== null) outcomes.push(outcome);
27399
27404
  }
27400
27405
  return {
@@ -27440,6 +27445,26 @@ var DeclaredDevices = class {
27440
27445
  return new Map(rows.map((row) => [row.stableId, row]));
27441
27446
  }
27442
27447
  /**
27448
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27449
+ *
27450
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27451
+ * is persisted rows, this is live objects. A row without an object must be
27452
+ * adopted; an object must be left exactly as it is.
27453
+ *
27454
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27455
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27456
+ * genuinely needs bringing up.
27457
+ */
27458
+ async readLiveByStableId() {
27459
+ try {
27460
+ const devices = await this.ports.devices.getAll();
27461
+ return new Map(devices.map((device) => [device.stableId, device]));
27462
+ } catch (err) {
27463
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27464
+ return /* @__PURE__ */ new Map();
27465
+ }
27466
+ }
27467
+ /**
27443
27468
  * One declaration: adopt what exists, create what does not.
27444
27469
  *
27445
27470
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27448,8 +27473,15 @@ var DeclaredDevices = class {
27448
27473
  * the declared name over the operator's rename. D49: that branch needs a
27449
27474
  * second read to agree.
27450
27475
  */
27451
- async applyDeclaration(declaration, integrationId, index) {
27476
+ async applyDeclaration(declaration, integrationId, index, live) {
27452
27477
  try {
27478
+ const alreadyLive = live.get(declaration.stableId);
27479
+ if (alreadyLive !== void 0) return {
27480
+ stableId: declaration.stableId,
27481
+ deviceId: alreadyLive.id,
27482
+ device: alreadyLive,
27483
+ created: false
27484
+ };
27453
27485
  let existing = index.get(declaration.stableId);
27454
27486
  if (existing === void 0) {
27455
27487
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -33456,9 +33488,20 @@ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33456
33488
  function escapeXml(value) {
33457
33489
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33458
33490
  }
33459
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33491
+ /**
33492
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
33493
+ *
33494
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
33495
+ * runs of whitespace by default, and a terminal's entire column alignment IS
33496
+ * runs of whitespace — Glances pads every field with spaces. Without it the
33497
+ * frame drew each line at roughly half its true width, crammed into the
33498
+ * top-left of a mostly-black image, while the SAME session over `attach`
33499
+ * looked perfect — which is exactly how the operator reported it. Measured in
33500
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33501
+ * collapsed against 178 px preserved.
33502
+ */
33460
33503
  async function renderTerminalJpeg(lines) {
33461
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33504
+ const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33462
33505
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
33463
33506
  return sharp(Buffer.from(svg)).jpeg({
33464
33507
  quality: 82,
@@ -39586,12 +39629,46 @@ var TerminalAddon = class extends BaseAddon {
39586
39629
  this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39587
39630
  }
39588
39631
  }
39632
+ if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
39589
39633
  const nodeId = outcome.device.config.get("nodeId");
39590
39634
  const profileId = outcome.device.config.get("profileId");
39591
39635
  const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39592
39636
  outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
39593
39637
  }
39594
39638
  }
39639
+ /**
39640
+ * A Terminal camera is a rendered screen. Object detection on it finds
39641
+ * nothing, forever, at full cost.
39642
+ *
39643
+ * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
39644
+ * through the detection pipeline at ~61 ms of inference each, plus 115
39645
+ * capture-scheduler requests a minute — against `detections=0`. Multiply by
39646
+ * one terminal per node and it is a standing tax on a hub that was already
39647
+ * shedding 86 % of its capture queue.
39648
+ *
39649
+ * Written through `setCameraSwitch`, which is the authority that already owns
39650
+ * this function — [D62] forbids a second store that disagrees with it. And
39651
+ * written ONLY on creation: an operator who deliberately turns detection back
39652
+ * on for a terminal must win, and a reconcile that re-asserted every pass
39653
+ * would silently overrule them once a minute.
39654
+ */
39655
+ async silenceAnalysisFor(deviceId) {
39656
+ for (const switchId of ["object-detection", "audio-analysis"]) try {
39657
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
39658
+ deviceId,
39659
+ switchId,
39660
+ enabled: false
39661
+ });
39662
+ } catch (err) {
39663
+ this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
39664
+ tags: { deviceId },
39665
+ meta: {
39666
+ switchId,
39667
+ error: err instanceof Error ? err.message : String(err)
39668
+ }
39669
+ });
39670
+ }
39671
+ }
39595
39672
  terminalInstanceControl() {
39596
39673
  return {
39597
39674
  listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",