@camstack/addon-terminal 0.1.53 → 0.1.54

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 (3) hide show
  1. package/dist/addon.js +731 -466
  2. package/dist/addon.mjs +731 -466
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -8094,6 +8094,20 @@ var RelocateJobSchema = object({
8094
8094
  * in its row, not in its name, so `MediaRelocateEngine` never reconciles one.
8095
8095
  */
8096
8096
  rowsReconciled: number().int().nonnegative().optional(),
8097
+ /**
8098
+ * Rows this run FORGOT because the file they name is not on disk.
8099
+ *
8100
+ * The mover derived the path from the row's own fields and `stat`ed it; an
8101
+ * ENOENT there is a per-path confirmation that the segment is gone (D296),
8102
+ * and the durable row is dropped through the same channel eviction uses. It
8103
+ * is reported for the same reason `rowsReconciled` is: this is a durable
8104
+ * mutation nobody asked for, and a migration that quietly erases hour rows is
8105
+ * the same failure as one that quietly skips them (D295).
8106
+ *
8107
+ * The production drain of 2026-08-30 would have reported 11 074 here — the
8108
+ * ledger claimed 5.65 GB of footage that no longer existed.
8109
+ */
8110
+ rowsForgotten: number().int().nonnegative().optional(),
8097
8111
  startedAt: number(),
8098
8112
  finishedAt: number().nullable(),
8099
8113
  error: string().nullable()
@@ -8457,6 +8471,91 @@ var RelocateResidueSchema = object({
8457
8471
  segments: number().int().nonnegative(),
8458
8472
  bytes: number().int().nonnegative()
8459
8473
  }).nullable();
8474
+ /**
8475
+ * Ask one location whether its durable hour rows describe the disk — the walk
8476
+ * (D319).
8477
+ *
8478
+ * `apply` DEFAULTS TO FALSE and that default is the product: the operator's
8479
+ * missing tool is the question, and the dry run is how they sanity-check the
8480
+ * destructive run before authorising it.
8481
+ */
8482
+ var LedgerWalkInputSchema = object({
8483
+ locationId: string().min(1),
8484
+ /** Forget the confirmed-absent rows, rather than only counting them. */
8485
+ apply: boolean().optional(),
8486
+ /** Narrow to one camera. */
8487
+ deviceId: number().int().positive().optional(),
8488
+ /** Narrow to these recording profiles; empty/absent = every profile. */
8489
+ profiles: array(string().min(1)).optional()
8490
+ });
8491
+ /** Why a whole walk did nothing. Every one leaves the ledger untouched. */
8492
+ var LedgerWalkRefusalSchema = _enum([
8493
+ "location-unknown",
8494
+ "source-writable",
8495
+ "no-ledger",
8496
+ "archive-unreadable",
8497
+ "anchor-absent",
8498
+ "anchor-unreadable",
8499
+ "anchor-moved"
8500
+ ]);
8501
+ _enum([
8502
+ "live-tail",
8503
+ "listing-error",
8504
+ "path-mismatch",
8505
+ "durable-refused"
8506
+ ]);
8507
+ /** Every skip reason, always present, always a number — so a reason that never
8508
+ * fired reports as zero rather than absent and the report shape is constant
8509
+ * between passes. Spelled out rather than `z.record` for exactly that. */
8510
+ var LedgerWalkSkipCountsSchema = object({
8511
+ "live-tail": number().int().nonnegative(),
8512
+ "listing-error": number().int().nonnegative(),
8513
+ "path-mismatch": number().int().nonnegative(),
8514
+ "durable-refused": number().int().nonnegative()
8515
+ });
8516
+ /** One camera's share of a walk, so a report names cameras and not rows. */
8517
+ var LedgerWalkDeviceReportSchema = object({
8518
+ deviceId: number().int(),
8519
+ hoursWalked: number().int().nonnegative(),
8520
+ hoursMissing: number().int().nonnegative(),
8521
+ ghostSegments: number().int().nonnegative(),
8522
+ ghostBytes: number().int().nonnegative(),
8523
+ forgottenSegments: number().int().nonnegative(),
8524
+ orphanFiles: number().int().nonnegative()
8525
+ });
8526
+ /**
8527
+ * What one walk claimed, listed, found and (only when armed) forgot.
8528
+ *
8529
+ * `archiveSegments` is the **M** and `segmentsClaimed` the **N** (D295), so a
8530
+ * walk that saw a fraction of the location is visible in its own report rather
8531
+ * than in the absence of one.
8532
+ */
8533
+ var LedgerWalkReportSchema = object({
8534
+ locationId: string(),
8535
+ applied: boolean(),
8536
+ refused: LedgerWalkRefusalSchema.nullable(),
8537
+ archiveSegments: number().int().nonnegative().nullable(),
8538
+ archiveBytes: number().int().nonnegative().nullable(),
8539
+ hoursClaimed: number().int().nonnegative(),
8540
+ hoursWalked: number().int().nonnegative(),
8541
+ hoursMissing: number().int().nonnegative(),
8542
+ /** `readdir` calls issued — the cost, stated in the unit that is paid. */
8543
+ listings: number().int().nonnegative(),
8544
+ segmentsClaimed: number().int().nonnegative(),
8545
+ ghostSegments: number().int().nonnegative(),
8546
+ ghostBytes: number().int().nonnegative(),
8547
+ ghostHoursWhole: number().int().nonnegative(),
8548
+ forgottenSegments: number().int().nonnegative(),
8549
+ forgottenBytes: number().int().nonnegative(),
8550
+ /** Files under a claimed hour that no durable row names. Never deleted. */
8551
+ orphanFiles: number().int().nonnegative(),
8552
+ orphanSample: array(string()).readonly(),
8553
+ hoursSkipped: number().int().nonnegative(),
8554
+ skippedByReason: LedgerWalkSkipCountsSchema,
8555
+ /** The walk stopped at its per-pass hour bound with claims unwalked. */
8556
+ bounded: boolean(),
8557
+ byDevice: array(LedgerWalkDeviceReportSchema).readonly()
8558
+ });
8460
8559
  /** How many rows a media pass would still act on against a given target — the
8461
8560
  * media lane's denominator AND its residue, from ONE derivation so the two can
8462
8561
  * never disagree. `null` = the count could not be taken. */
@@ -19017,13 +19116,15 @@ var ListGroupsPageSchema = object({
19017
19116
  groups: array(AnalyticsGroupRecordSchema).readonly(),
19018
19117
  nextCursor: string().nullable()
19019
19118
  });
19119
+ var KEY_EVENTS_DEFAULT_LIMIT = 50;
19120
+ var KEY_EVENTS_MAX_LIMIT = 200;
19020
19121
  var KeyEventQueryInput = object({
19021
19122
  deviceId: number(),
19022
19123
  /** Window lower bound (track firstSeen ≥ since). */
19023
19124
  since: number(),
19024
19125
  /** Window upper bound (track firstSeen ≤ until). */
19025
19126
  until: number(),
19026
- limit: number().int().min(1).max(200).default(50),
19127
+ limit: number().int().min(1).max(KEY_EVENTS_MAX_LIMIT).default(KEY_EVENTS_DEFAULT_LIMIT),
19027
19128
  /** Drop tracks scoring below this importance. */
19028
19129
  minImportance: number().min(0).max(1).optional(),
19029
19130
  /** Restrict to a single class (e.g. 'person'). */
@@ -19045,6 +19146,32 @@ var KeyEventSchema = object({
19045
19146
  ...TrackFlagFields,
19046
19147
  ...TrackRetrainFields
19047
19148
  });
19149
+ /** `getKeyEvents`' window and filters, asked of a SET of cameras at once. */
19150
+ var KeyEventBatchQueryInput = object({
19151
+ deviceIds: array(number()).min(1).max(200),
19152
+ since: number(),
19153
+ until: number(),
19154
+ /** Applied PER CAMERA, exactly as `getKeyEvents.limit` is — never a total
19155
+ * across the set, which would let a busy camera starve a quiet one of its
19156
+ * rows and change what the merged feed contains. */
19157
+ limit: number().int().min(1).max(KEY_EVENTS_MAX_LIMIT).default(KEY_EVENTS_DEFAULT_LIMIT),
19158
+ minImportance: number().min(0).max(1).optional(),
19159
+ classFilter: string().optional()
19160
+ });
19161
+ /**
19162
+ * One camera's key events in a batch answer.
19163
+ *
19164
+ * The row exists for every requested id. `getKeyEvents` degrades to `[]` on
19165
+ * error rather than throwing, so a camera whose store read failed and one with
19166
+ * no events in the window were ALREADY indistinguishable per camera — the
19167
+ * batch does not make that worse, and the row keeps the deviceId the single
19168
+ * method's output never carried (the caller used to stamp it from the fan-out
19169
+ * key, which only worked because there was one query per camera).
19170
+ */
19171
+ var KeyEventsForDeviceSchema = object({
19172
+ deviceId: number(),
19173
+ events: array(KeyEventSchema).readonly()
19174
+ });
19048
19175
  object({
19049
19176
  trackId: string(),
19050
19177
  className: string(),
@@ -19314,7 +19441,7 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19314
19441
  until: number().optional(),
19315
19442
  kinds: array(string()).optional(),
19316
19443
  limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
19317
- }), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(object({
19444
+ }), array(SensorEventSchema).readonly()), method(KeyEventQueryInput, array(KeyEventSchema).readonly()), method(KeyEventBatchQueryInput, array(KeyEventsForDeviceSchema).readonly()), method(object({
19318
19445
  deviceId: number(),
19319
19446
  since: number(),
19320
19447
  until: number(),
@@ -28829,6 +28956,9 @@ method(object({
28829
28956
  }), method(RelocateResidueInputSchema, RelocateResidueSchema, {
28830
28957
  kind: "query",
28831
28958
  auth: "admin"
28959
+ }), method(LedgerWalkInputSchema, LedgerWalkReportSchema, {
28960
+ kind: "mutation",
28961
+ auth: "admin"
28832
28962
  }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
28833
28963
  kind: "mutation",
28834
28964
  auth: "admin"
@@ -29220,6 +29350,25 @@ var SceneMonitorStatusSchema = object({
29220
29350
  monitors: array(SceneMonitorSchema),
29221
29351
  lastFetchedAt: number()
29222
29352
  });
29353
+ /**
29354
+ * One camera's row in a `listScenesBatch` answer.
29355
+ *
29356
+ * `status` is NULLABLE and the nullability is the whole point. `scene-monitor`
29357
+ * is a `defaultActive` wrapper, so every camera is asked; a camera whose
29358
+ * provider is absent (pipeline-analytics not deployed, the post-processing node
29359
+ * down) cannot answer, and that is not the same fact as a camera with no scenes
29360
+ * configured. Fanned out per camera the difference was visible — one query
29361
+ * errored while the others resolved — and a batch that returned only the rows
29362
+ * it managed would have destroyed it, silently, by making an unreachable camera
29363
+ * indistinguishable from one that answered `monitors: []`.
29364
+ *
29365
+ * So: EVERY requested deviceId gets a row. `status: null` means "this camera
29366
+ * could not be read"; `status.monitors: []` means "read, and it has none".
29367
+ */
29368
+ var SceneMonitorStatusForDeviceSchema = object({
29369
+ deviceId: number(),
29370
+ status: SceneMonitorStatusSchema.nullable()
29371
+ });
29223
29372
  var sceneMonitorCapability = {
29224
29373
  name: "scene-monitor",
29225
29374
  scope: "device",
@@ -29229,6 +29378,22 @@ var sceneMonitorCapability = {
29229
29378
  deviceTypes: [DeviceType.Camera],
29230
29379
  methods: {
29231
29380
  listScenes: method(object({ deviceId: number() }), SceneMonitorStatusSchema),
29381
+ /**
29382
+ * The same answer, for a SET of cameras, in one round trip.
29383
+ *
29384
+ * `/scenes` renders every camera and re-reads them on a 30s safety-net
29385
+ * poll behind the push slice. Fanned out client-side that was one query
29386
+ * per camera — 29 round trips through the browser, the hub and the
29387
+ * post-analysis runner every 30 seconds to read an in-memory map the
29388
+ * owner had already merged. The work is unchanged (`statusFor` per
29389
+ * device, all in-process at the owner); what collapses is the transport.
29390
+ *
29391
+ * A camera that cannot answer still gets a row, with `status: null` —
29392
+ * see {@link SceneMonitorStatusForDeviceSchema}. Never fewer rows than
29393
+ * ids: a caller that asked for twenty-nine and got twenty-seven cannot
29394
+ * tell which two are missing, or that any are.
29395
+ */
29396
+ listScenesBatch: method(object({ deviceIds: array(number()).min(1).max(200) }), array(SceneMonitorStatusForDeviceSchema).readonly()),
29232
29397
  createScene: method(object({
29233
29398
  deviceId: number(),
29234
29399
  label: string(),
@@ -31064,6 +31229,27 @@ var CameraOccupancySnapshotSchema = object({
31064
31229
  stationaryObjects: array(StationaryObjectSchema).readonly().optional()
31065
31230
  });
31066
31231
  /**
31232
+ * One camera's row in a `getCurrentSnapshotBatch` answer.
31233
+ *
31234
+ * THREE outcomes, and the single-camera method could only express two of them
31235
+ * because `snapshot: null` was already spoken for:
31236
+ *
31237
+ * - `read: 'read'`, `snapshot` present — the live occupancy reading.
31238
+ * - `read: 'read'`, `snapshot: null` — read, and this camera has no reading
31239
+ * yet: no frame since boot, and no parked-object registry to hydrate from.
31240
+ * - `read: 'unreadable'` — the owner could not answer for this
31241
+ * camera. `snapshot` is null, and it does NOT mean "nothing parked here".
31242
+ *
31243
+ * Collapsing the last two is the failure this field exists to prevent: a
31244
+ * hydration that threw would otherwise render as an empty Stationary section,
31245
+ * which is a definite claim about a camera nobody could read.
31246
+ */
31247
+ var CameraOccupancySnapshotForDeviceSchema = object({
31248
+ deviceId: number(),
31249
+ read: _enum(["read", "unreadable"]),
31250
+ snapshot: CameraOccupancySnapshotSchema.nullable()
31251
+ });
31252
+ /**
31067
31253
  * Time-series resolution. The history methods return one bucket per
31068
31254
  * step over the requested range. Smaller resolutions cost more
31069
31255
  * memory + bandwidth; bound to discrete steps so caller cannot ask
@@ -31120,6 +31306,20 @@ var zoneAnalyticsCapability = {
31120
31306
  * (no inference result emitted since boot or since binding was
31121
31307
  * activated). */
31122
31308
  getCurrentSnapshot: method(object({ deviceId: number() }), CameraOccupancySnapshotSchema.nullable()),
31309
+ /**
31310
+ * The same snapshot, for a SET of cameras, in one round trip.
31311
+ *
31312
+ * The Events page's Stationary section polls this every 15s for every
31313
+ * selected camera. Fanned out client-side that is one query per camera to
31314
+ * read a `Map.get` at the owner — the answer costs nothing, the round trip
31315
+ * costs everything. Batched, N transports become one and the per-device
31316
+ * work is unchanged.
31317
+ *
31318
+ * Every requested deviceId gets a row, tagged `read` — see
31319
+ * {@link CameraOccupancySnapshotForDeviceSchema}. A camera the owner could
31320
+ * not answer for is `'unreadable'`, never an empty reading.
31321
+ */
31322
+ getCurrentSnapshotBatch: method(object({ deviceIds: array(number()).min(1).max(200) }), array(CameraOccupancySnapshotForDeviceSchema).readonly()),
31123
31323
  /** Time-series object count inside one zone. `className` optional —
31124
31324
  * omit to count every class in the zone. */
31125
31325
  getZoneHistory: method(object({
@@ -35539,6 +35739,12 @@ Object.freeze({
35539
35739
  addonId: null,
35540
35740
  access: "view"
35541
35741
  },
35742
+ "pipelineAnalytics.getKeyEventsBatch": {
35743
+ capName: "pipeline-analytics",
35744
+ capScope: "device",
35745
+ addonId: null,
35746
+ access: "view"
35747
+ },
35542
35748
  "pipelineAnalytics.getMotionEvents": {
35543
35749
  capName: "pipeline-analytics",
35544
35750
  capScope: "device",
@@ -36715,6 +36921,12 @@ Object.freeze({
36715
36921
  addonId: null,
36716
36922
  access: "view"
36717
36923
  },
36924
+ "recording.reconcileLedgerAgainstDisk": {
36925
+ capName: "recording",
36926
+ capScope: "system",
36927
+ addonId: null,
36928
+ access: "create"
36929
+ },
36718
36930
  "recording.refreshStorageLocationsForMigration": {
36719
36931
  capName: "recording",
36720
36932
  capScope: "system",
@@ -36841,6 +37053,12 @@ Object.freeze({
36841
37053
  addonId: null,
36842
37054
  access: "view"
36843
37055
  },
37056
+ "sceneMonitor.listScenesBatch": {
37057
+ capName: "scene-monitor",
37058
+ capScope: "device",
37059
+ addonId: null,
37060
+ access: "view"
37061
+ },
36844
37062
  "sceneMonitor.recheckNow": {
36845
37063
  capName: "scene-monitor",
36846
37064
  capScope: "device",
@@ -38197,6 +38415,12 @@ Object.freeze({
38197
38415
  addonId: null,
38198
38416
  access: "view"
38199
38417
  },
38418
+ "zoneAnalytics.getCurrentSnapshotBatch": {
38419
+ capName: "zone-analytics",
38420
+ capScope: "device",
38421
+ addonId: null,
38422
+ access: "view"
38423
+ },
38200
38424
  "zoneAnalytics.getUnzonedHistory": {
38201
38425
  capName: "zone-analytics",
38202
38426
  capScope: "device",
@@ -39201,6 +39425,11 @@ Object.freeze({
39201
39425
  form: "single",
39202
39426
  optional: false
39203
39427
  }],
39428
+ "pipelineAnalytics.getKeyEventsBatch": [{
39429
+ name: "deviceIds",
39430
+ form: "array",
39431
+ optional: false
39432
+ }],
39204
39433
  "pipelineAnalytics.getMotionEvents": [{
39205
39434
  name: "deviceId",
39206
39435
  form: "single",
@@ -39641,6 +39870,11 @@ Object.freeze({
39641
39870
  form: "single",
39642
39871
  optional: false
39643
39872
  }],
39873
+ "recording.reconcileLedgerAgainstDisk": [{
39874
+ name: "deviceId",
39875
+ form: "single",
39876
+ optional: true
39877
+ }],
39644
39878
  "recording.relocateFootage": [{
39645
39879
  name: "deviceId",
39646
39880
  form: "single",
@@ -39706,6 +39940,11 @@ Object.freeze({
39706
39940
  form: "single",
39707
39941
  optional: false
39708
39942
  }],
39943
+ "sceneMonitor.listScenesBatch": [{
39944
+ name: "deviceIds",
39945
+ form: "array",
39946
+ optional: false
39947
+ }],
39709
39948
  "sceneMonitor.recheckNow": [{
39710
39949
  name: "deviceId",
39711
39950
  form: "single",
@@ -39967,6 +40206,11 @@ Object.freeze({
39967
40206
  form: "single",
39968
40207
  optional: false
39969
40208
  }],
40209
+ "zoneAnalytics.getCurrentSnapshotBatch": [{
40210
+ name: "deviceIds",
40211
+ form: "array",
40212
+ optional: false
40213
+ }],
39970
40214
  "zoneAnalytics.getUnzonedHistory": [{
39971
40215
  name: "deviceId",
39972
40216
  form: "single",
@@ -40540,6 +40784,111 @@ function resolveGlancesCursesShimEnv(options) {
40540
40784
  return { PYTHONPATH: existing ? `${options.shimDir}:${existing}` : options.shimDir };
40541
40785
  }
40542
40786
  //#endregion
40787
+ //#region src/profile-settings.ts
40788
+ var GLANCES_PLUGINS = [
40789
+ {
40790
+ key: "showCpu",
40791
+ plugin: "cpu",
40792
+ label: "CPU"
40793
+ },
40794
+ {
40795
+ key: "showMem",
40796
+ plugin: "mem",
40797
+ label: "Memory"
40798
+ },
40799
+ {
40800
+ key: "showLoad",
40801
+ plugin: "load",
40802
+ label: "Load"
40803
+ },
40804
+ {
40805
+ key: "showNetwork",
40806
+ plugin: "network",
40807
+ label: "Network"
40808
+ },
40809
+ {
40810
+ key: "showDiskIo",
40811
+ plugin: "diskio",
40812
+ label: "Disk I/O"
40813
+ },
40814
+ {
40815
+ key: "showFs",
40816
+ plugin: "fs",
40817
+ label: "Filesystems"
40818
+ },
40819
+ {
40820
+ key: "showProcessList",
40821
+ plugin: "processlist",
40822
+ label: "Process list"
40823
+ },
40824
+ {
40825
+ key: "showContainers",
40826
+ plugin: "containers",
40827
+ label: "Containers"
40828
+ },
40829
+ {
40830
+ key: "showSensors",
40831
+ plugin: "sensors",
40832
+ label: "Sensors"
40833
+ }
40834
+ ];
40835
+ function glancesBooleanField(key, label) {
40836
+ return {
40837
+ type: "boolean",
40838
+ key,
40839
+ label,
40840
+ default: true,
40841
+ style: "switch"
40842
+ };
40843
+ }
40844
+ function glancesSettingsSchema() {
40845
+ return { sections: [{
40846
+ id: "glances-panels",
40847
+ title: "Glances panels",
40848
+ description: "Turn off a panel to pass --disable-plugin to this Terminal only. All on is the measured default (~1% of one core at the camera grid).",
40849
+ columns: 2,
40850
+ fields: GLANCES_PLUGINS.map((plugin) => glancesBooleanField(plugin.key, plugin.label))
40851
+ }] };
40852
+ }
40853
+ function settingsSchemaForProfile(profileId) {
40854
+ if (profileId === "glances") return glancesSettingsSchema();
40855
+ return null;
40856
+ }
40857
+ function glancesSettingsToArgs(settings) {
40858
+ const disabled = GLANCES_PLUGINS.filter((plugin) => settings[plugin.key] === false).map((plugin) => plugin.plugin);
40859
+ if (disabled.length === 0) return [];
40860
+ return ["--disable-plugin", disabled.join(",")];
40861
+ }
40862
+ function sanitizeProfileSettings(profileId, raw) {
40863
+ if (profileId !== "glances") return {};
40864
+ const bag = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
40865
+ const out = {
40866
+ showCpu: true,
40867
+ showMem: true,
40868
+ showLoad: true,
40869
+ showNetwork: true,
40870
+ showDiskIo: true,
40871
+ showFs: true,
40872
+ showProcessList: true,
40873
+ showContainers: true,
40874
+ showSensors: true
40875
+ };
40876
+ for (const plugin of GLANCES_PLUGINS) {
40877
+ const value = bag[plugin.key];
40878
+ if (typeof value === "boolean") out[plugin.key] = value;
40879
+ }
40880
+ return out;
40881
+ }
40882
+ function profileSettingsToArgs(profileId, settings) {
40883
+ if (profileId !== "glances") return [];
40884
+ return glancesSettingsToArgs(sanitizeProfileSettings(profileId, settings));
40885
+ }
40886
+ function spawnArgsForInstance(input) {
40887
+ const extra = profileSettingsToArgs(input.profileId, input.profileSettings);
40888
+ if (input.instanceArgs.length > 0) return [...input.instanceArgs, ...extra];
40889
+ if (extra.length > 0) return [...input.profileArgs, ...extra];
40890
+ }
40891
+ //#endregion
40543
40892
  //#region src/pty.ts
40544
40893
  /**
40545
40894
  * Minimal pty abstraction. The manager depends on this interface, never on
@@ -40721,6 +41070,356 @@ async function silenceAnalysisFor(deps, deviceId) {
40721
41070
  if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
40722
41071
  }
40723
41072
  //#endregion
41073
+ //#region src/terminal-camera-declarations.ts
41074
+ /**
41075
+ * Feed DeclaredDevices every live declaration plus one deterministic orphan
41076
+ * batch. The generic sweep intentionally refuses an over-limit set; selecting
41077
+ * a batch here drains large historical Terminal orphan sets across convergence
41078
+ * passes without weakening that global safety guard.
41079
+ */
41080
+ function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
41081
+ if (!integrationId) return [];
41082
+ const declared = new Set(declarations.map((camera) => camera.stableId));
41083
+ const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
41084
+ return [...owned.filter((row) => declared.has(row.stableId)), ...owned.filter((row) => !declared.has(row.stableId)).sort((left, right) => left.id - right.id).slice(0, 32)];
41085
+ }
41086
+ /** Explicit persisted instances, never the node × profile template matrix. */
41087
+ function buildTerminalInstanceCameraDeclarations(instances) {
41088
+ return instances.filter((instance) => instance.enabled).map((instance) => ({
41089
+ stableId: instance.cameraStableId,
41090
+ name: instance.name,
41091
+ config: {
41092
+ instanceId: instance.id,
41093
+ nodeId: instance.nodeId,
41094
+ profileId: instance.profileId,
41095
+ profileLabel: instance.profileLabel
41096
+ }
41097
+ }));
41098
+ }
41099
+ /**
41100
+ * `DeviceConfig` materializes schema defaults in memory, so comparing
41101
+ * `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
41102
+ * inspect the raw persisted blob to make the profile migration durable.
41103
+ */
41104
+ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
41105
+ return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
41106
+ }
41107
+ //#endregion
41108
+ //#region src/terminal-cell-runs.ts
41109
+ var TERMINAL_DEFAULT_FG = "#d7dce2";
41110
+ var TERMINAL_DEFAULT_BG = "#0b0d10";
41111
+ /**
41112
+ * The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
41113
+ * frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
41114
+ * whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
41115
+ * near-black background at 13px. Index 7 IS the default foreground, so plain
41116
+ * `CSI 37m` text renders identically to unstyled text.
41117
+ */
41118
+ var TERMINAL_ANSI_PALETTE = [
41119
+ "#282c34",
41120
+ "#e06c75",
41121
+ "#98c379",
41122
+ "#e5c07b",
41123
+ "#61afef",
41124
+ "#c678dd",
41125
+ "#56b6c2",
41126
+ TERMINAL_DEFAULT_FG,
41127
+ "#5c6370",
41128
+ "#ef596f",
41129
+ "#89ca78",
41130
+ "#f0c674",
41131
+ "#6cb6ff",
41132
+ "#d55fde",
41133
+ "#2bbac5",
41134
+ "#ffffff"
41135
+ ];
41136
+ /** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
41137
+ var TERMINAL_CUBE_LEVELS = [
41138
+ 0,
41139
+ 95,
41140
+ 135,
41141
+ 175,
41142
+ 215,
41143
+ 255
41144
+ ];
41145
+ var TERMINAL_CUBE_FIRST = 16;
41146
+ var TERMINAL_GRAYSCALE_FIRST = 232;
41147
+ var TERMINAL_GRAYSCALE_BASE = 8;
41148
+ var TERMINAL_GRAYSCALE_STEP = 10;
41149
+ /** SGR 2 keeps the foreground legible; it must not become the background. */
41150
+ var TERMINAL_DIM_WEIGHT = .6;
41151
+ function channel(value) {
41152
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
41153
+ }
41154
+ function hex(red, green, blue) {
41155
+ return `#${channel(red)}${channel(green)}${channel(blue)}`;
41156
+ }
41157
+ function parseHex(color) {
41158
+ return [
41159
+ Number.parseInt(color.slice(1, 3), 16),
41160
+ Number.parseInt(color.slice(3, 5), 16),
41161
+ Number.parseInt(color.slice(5, 7), 16)
41162
+ ];
41163
+ }
41164
+ /** Resolve an xterm palette index (0-255) to a hex colour. */
41165
+ function terminalPaletteColor(index) {
41166
+ const ansi = TERMINAL_ANSI_PALETTE[index];
41167
+ if (ansi !== void 0) return ansi;
41168
+ if (index >= TERMINAL_GRAYSCALE_FIRST) {
41169
+ const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
41170
+ return hex(level, level, level);
41171
+ }
41172
+ if (index >= TERMINAL_CUBE_FIRST) {
41173
+ const offset = index - TERMINAL_CUBE_FIRST;
41174
+ return hex(TERMINAL_CUBE_LEVELS[Math.floor(offset / 36) % 6] ?? 0, TERMINAL_CUBE_LEVELS[Math.floor(offset / 6) % 6] ?? 0, TERMINAL_CUBE_LEVELS[offset % 6] ?? 0);
41175
+ }
41176
+ return TERMINAL_DEFAULT_FG;
41177
+ }
41178
+ /** Resolve a 0xRRGGBB truecolor value to a hex colour. */
41179
+ function terminalRgbColor(value) {
41180
+ return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
41181
+ }
41182
+ function blend(color, toward, weight) {
41183
+ const [red, green, blue] = parseHex(color);
41184
+ const [targetRed, targetGreen, targetBlue] = parseHex(toward);
41185
+ return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
41186
+ }
41187
+ function resolveForeground(cell) {
41188
+ if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
41189
+ if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
41190
+ return TERMINAL_DEFAULT_FG;
41191
+ }
41192
+ function resolveBackground(cell) {
41193
+ if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
41194
+ if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
41195
+ return TERMINAL_DEFAULT_BG;
41196
+ }
41197
+ /**
41198
+ * Resolve one cell's attributes into concrete colours.
41199
+ *
41200
+ * Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
41201
+ * defaults is still a visible swap rather than a no-op — that is how a selected
41202
+ * or highlighted row in Glances reads. Invisible is then conceal-by-equality
41203
+ * (foreground painted in its own background): the cell keeps its columns, which
41204
+ * a dropped cell would not, and dropping it would shift the whole rest of the
41205
+ * row left.
41206
+ */
41207
+ function resolveCellStyle(cell) {
41208
+ const inverse = cell.isInverse() !== 0;
41209
+ const plainFg = resolveForeground(cell);
41210
+ const plainBg = resolveBackground(cell);
41211
+ const background = inverse ? plainFg : plainBg;
41212
+ let foreground = inverse ? plainBg : plainFg;
41213
+ if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
41214
+ if (cell.isInvisible() !== 0) foreground = background;
41215
+ return {
41216
+ fg: foreground === "#d7dce2" ? null : foreground,
41217
+ bg: background === "#0b0d10" ? null : background,
41218
+ bold: cell.isBold() !== 0
41219
+ };
41220
+ }
41221
+ function sameStyle(left, right) {
41222
+ return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
41223
+ }
41224
+ /**
41225
+ * Merge adjacent same-style cells into runs, then drop the trailing run of
41226
+ * default-styled whitespace so a row costs what it draws — the same trim
41227
+ * `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
41228
+ * a green bar of spaces out to the right margin is a pixel Glances drew.
41229
+ */
41230
+ function buildCellRuns(cells) {
41231
+ const runs = [];
41232
+ let text = "";
41233
+ let style = null;
41234
+ for (const cell of cells) {
41235
+ if (style !== null && sameStyle(style, cell.style)) {
41236
+ text += cell.text;
41237
+ continue;
41238
+ }
41239
+ if (style !== null) runs.push({
41240
+ text,
41241
+ ...style
41242
+ });
41243
+ text = cell.text;
41244
+ style = cell.style;
41245
+ }
41246
+ if (style !== null) runs.push({
41247
+ text,
41248
+ ...style
41249
+ });
41250
+ while (runs.length > 0) {
41251
+ const last = runs[runs.length - 1];
41252
+ if (last === void 0 || last.bg !== null) break;
41253
+ const trimmed = last.text.replace(/\s+$/u, "");
41254
+ if (trimmed === last.text) break;
41255
+ if (trimmed === "") {
41256
+ runs.pop();
41257
+ continue;
41258
+ }
41259
+ runs[runs.length - 1] = {
41260
+ ...last,
41261
+ text: trimmed
41262
+ };
41263
+ break;
41264
+ }
41265
+ return runs;
41266
+ }
41267
+ /**
41268
+ * Monospace families to try, in order — NOT one family and a generic.
41269
+ *
41270
+ * A terminal screen is mostly box-drawing and block characters, and a font
41271
+ * without them renders the frame as noise rather than as missing detail.
41272
+ * `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
41273
+ * DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
41274
+ * coverage is not, and its Glances camera came out unreadable while the hub's
41275
+ * was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
41276
+ *
41277
+ * `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
41278
+ * present on every install, and derived from DejaVu Sans Mono — the same glyph
41279
+ * coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
41280
+ * generic stays last so a host with none of them still draws something.
41281
+ */
41282
+ var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
41283
+ var TERMINAL_FONT_SIZE = 13;
41284
+ var TERMINAL_TEXT_MARGIN_X = 8;
41285
+ var TERMINAL_ROW_HEIGHT = 15;
41286
+ var TERMINAL_BASELINE_Y = 18;
41287
+ /**
41288
+ * Distance from a row's baseline up to the top of its cell box. Chosen so
41289
+ * consecutive rows tile exactly: row N's box runs from `baseline - this` for
41290
+ * `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
41291
+ * bar that stopped short would draw as stripes across a `CSI 42m` panel.
41292
+ */
41293
+ var TERMINAL_CELL_ASCENT = 11.5;
41294
+ var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
41295
+ /** Two decimals is under a tenth of a pixel and keeps the SVG small. */
41296
+ function coordinate(value) {
41297
+ return String(Number(value.toFixed(2)));
41298
+ }
41299
+ function escapeXml(value) {
41300
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
41301
+ }
41302
+ /**
41303
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
41304
+ *
41305
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
41306
+ * runs of whitespace by default, and a terminal's entire column alignment IS
41307
+ * runs of whitespace — Glances pads every field with spaces. Without it the
41308
+ * frame drew each line at roughly half its true width, crammed into the
41309
+ * top-left of a mostly-black image, while the SAME session over `attach`
41310
+ * looked perfect — which is exactly how the operator reported it. Measured in
41311
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
41312
+ * collapsed against 178 px preserved.
41313
+ *
41314
+ * Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
41315
+ * never appended to the one before it, so the background rects and the glyphs
41316
+ * are placed off the same grid and cannot drift apart. `textLength` is emitted
41317
+ * with it because it is the correct declaration and renderers that honour it
41318
+ * get an exact grid — but it is not what makes this work: librsvg, which sharp
41319
+ * uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
41320
+ * 600 px still drew its natural 937 px). The anchoring is the guarantee.
41321
+ */
41322
+ function renderTerminalSvg(rows) {
41323
+ const backgrounds = [];
41324
+ const texts = [];
41325
+ rows.slice(0, 40).forEach((row, index) => {
41326
+ const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
41327
+ const top = baseline - TERMINAL_CELL_ASCENT;
41328
+ let column = 0;
41329
+ for (const run of row) {
41330
+ if (column >= 120) break;
41331
+ const clipped = clipRun(run, 120 - column);
41332
+ const columns = [...clipped].length;
41333
+ if (columns === 0) continue;
41334
+ const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
41335
+ const width = columns * TERMINAL_CELL_WIDTH;
41336
+ if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
41337
+ if (clipped.trim() !== "") {
41338
+ const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
41339
+ const weight = run.bold ? " font-weight=\"bold\"" : "";
41340
+ texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
41341
+ }
41342
+ column += columns;
41343
+ }
41344
+ });
41345
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="${TERMINAL_DEFAULT_BG}"/>${backgrounds.join("")}<g fill="${TERMINAL_DEFAULT_FG}" font-family="${TERMINAL_FONT_STACK}" font-size="${String(TERMINAL_FONT_SIZE)}">${texts.join("")}</g></svg>`;
41346
+ }
41347
+ /** Cut a run to the columns still left in the row, by code point not unit. */
41348
+ function clipRun(run, remaining) {
41349
+ const points = [...run.text];
41350
+ return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
41351
+ }
41352
+ async function renderTerminalJpeg(rows) {
41353
+ return sharp(Buffer.from(renderTerminalSvg(rows))).jpeg({
41354
+ quality: 82,
41355
+ chromaSubsampling: "4:2:0"
41356
+ }).toBuffer();
41357
+ }
41358
+ //#endregion
41359
+ //#region src/terminal-camera-device.ts
41360
+ var terminalCameraSchema = object({
41361
+ instanceId: string().min(1).optional(),
41362
+ nodeId: string().min(1),
41363
+ profileId: string().min(1).default("monitor"),
41364
+ profileLabel: string().min(1).default("BTM")
41365
+ });
41366
+ var relay = null;
41367
+ function installTerminalCameraRelay(next) {
41368
+ relay = next;
41369
+ }
41370
+ var TerminalCameraDevice = class extends BaseDevice {
41371
+ features = [DeviceFeature.NativeSnapshot];
41372
+ constructor(ctx) {
41373
+ super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
41374
+ this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
41375
+ if (deviceId !== this.id) return [];
41376
+ return this.catalog();
41377
+ } });
41378
+ this.ctx.registerNativeCap(snapshotCapability, {
41379
+ getSnapshot: async ({ deviceId }) => {
41380
+ if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
41381
+ const activeRelay = relay;
41382
+ if (!activeRelay) throw new Error("terminal camera relay is unavailable");
41383
+ return {
41384
+ base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
41385
+ contentType: "image/jpeg"
41386
+ };
41387
+ },
41388
+ invalidateCache: async () => {}
41389
+ });
41390
+ this.markOnline(true);
41391
+ }
41392
+ async catalog() {
41393
+ const activeRelay = relay;
41394
+ if (!activeRelay) throw new Error("terminal camera relay is unavailable");
41395
+ const nodeId = this.config.get("nodeId");
41396
+ const profileId = this.config.get("profileId");
41397
+ const instanceId = this.relayInstanceId();
41398
+ return [{
41399
+ camStreamId: profileId,
41400
+ kind: "pull-http",
41401
+ url: activeRelay.streamUrl(instanceId, nodeId, profileId),
41402
+ codec: "h264",
41403
+ resolution: {
41404
+ width: 960,
41405
+ height: 640
41406
+ },
41407
+ fps: 2,
41408
+ label: this.config.get("profileLabel")
41409
+ }];
41410
+ }
41411
+ setNodeOnline(online) {
41412
+ this.markOnline(online);
41413
+ if (!online) relay?.closeInstance(this.relayInstanceId());
41414
+ }
41415
+ async removeDevice() {
41416
+ await relay?.closeInstance(this.relayInstanceId());
41417
+ }
41418
+ relayInstanceId() {
41419
+ return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
41420
+ }
41421
+ };
41422
+ //#endregion
40724
41423
  //#region ../../node_modules/@xterm/addon-serialize/lib/addon-serialize.js
40725
41424
  var require_addon_serialize = /* @__PURE__ */ __commonJSMin(((exports, module) => {
40726
41425
  (function(e, t) {
@@ -45528,174 +46227,9 @@ var require_xterm_headless = /* @__PURE__ */ __commonJSMin(((exports) => {
45528
46227
  })();
45529
46228
  }));
45530
46229
  //#endregion
45531
- //#region src/terminal-cell-runs.ts
46230
+ //#region src/xterm-screen.ts
45532
46231
  var import_addon_serialize = require_addon_serialize();
45533
46232
  var import_xterm_headless = require_xterm_headless();
45534
- var TERMINAL_DEFAULT_FG = "#d7dce2";
45535
- var TERMINAL_DEFAULT_BG = "#0b0d10";
45536
- /**
45537
- * The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
45538
- * frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
45539
- * whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
45540
- * near-black background at 13px. Index 7 IS the default foreground, so plain
45541
- * `CSI 37m` text renders identically to unstyled text.
45542
- */
45543
- var TERMINAL_ANSI_PALETTE = [
45544
- "#282c34",
45545
- "#e06c75",
45546
- "#98c379",
45547
- "#e5c07b",
45548
- "#61afef",
45549
- "#c678dd",
45550
- "#56b6c2",
45551
- TERMINAL_DEFAULT_FG,
45552
- "#5c6370",
45553
- "#ef596f",
45554
- "#89ca78",
45555
- "#f0c674",
45556
- "#6cb6ff",
45557
- "#d55fde",
45558
- "#2bbac5",
45559
- "#ffffff"
45560
- ];
45561
- /** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
45562
- var TERMINAL_CUBE_LEVELS = [
45563
- 0,
45564
- 95,
45565
- 135,
45566
- 175,
45567
- 215,
45568
- 255
45569
- ];
45570
- var TERMINAL_CUBE_FIRST = 16;
45571
- var TERMINAL_GRAYSCALE_FIRST = 232;
45572
- var TERMINAL_GRAYSCALE_BASE = 8;
45573
- var TERMINAL_GRAYSCALE_STEP = 10;
45574
- /** SGR 2 keeps the foreground legible; it must not become the background. */
45575
- var TERMINAL_DIM_WEIGHT = .6;
45576
- function channel(value) {
45577
- return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
45578
- }
45579
- function hex(red, green, blue) {
45580
- return `#${channel(red)}${channel(green)}${channel(blue)}`;
45581
- }
45582
- function parseHex(color) {
45583
- return [
45584
- Number.parseInt(color.slice(1, 3), 16),
45585
- Number.parseInt(color.slice(3, 5), 16),
45586
- Number.parseInt(color.slice(5, 7), 16)
45587
- ];
45588
- }
45589
- /** Resolve an xterm palette index (0-255) to a hex colour. */
45590
- function terminalPaletteColor(index) {
45591
- const ansi = TERMINAL_ANSI_PALETTE[index];
45592
- if (ansi !== void 0) return ansi;
45593
- if (index >= TERMINAL_GRAYSCALE_FIRST) {
45594
- const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
45595
- return hex(level, level, level);
45596
- }
45597
- if (index >= TERMINAL_CUBE_FIRST) {
45598
- const offset = index - TERMINAL_CUBE_FIRST;
45599
- return hex(TERMINAL_CUBE_LEVELS[Math.floor(offset / 36) % 6] ?? 0, TERMINAL_CUBE_LEVELS[Math.floor(offset / 6) % 6] ?? 0, TERMINAL_CUBE_LEVELS[offset % 6] ?? 0);
45600
- }
45601
- return TERMINAL_DEFAULT_FG;
45602
- }
45603
- /** Resolve a 0xRRGGBB truecolor value to a hex colour. */
45604
- function terminalRgbColor(value) {
45605
- return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
45606
- }
45607
- function blend(color, toward, weight) {
45608
- const [red, green, blue] = parseHex(color);
45609
- const [targetRed, targetGreen, targetBlue] = parseHex(toward);
45610
- return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
45611
- }
45612
- function resolveForeground(cell) {
45613
- if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
45614
- if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
45615
- return TERMINAL_DEFAULT_FG;
45616
- }
45617
- function resolveBackground(cell) {
45618
- if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
45619
- if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
45620
- return TERMINAL_DEFAULT_BG;
45621
- }
45622
- /**
45623
- * Resolve one cell's attributes into concrete colours.
45624
- *
45625
- * Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
45626
- * defaults is still a visible swap rather than a no-op — that is how a selected
45627
- * or highlighted row in Glances reads. Invisible is then conceal-by-equality
45628
- * (foreground painted in its own background): the cell keeps its columns, which
45629
- * a dropped cell would not, and dropping it would shift the whole rest of the
45630
- * row left.
45631
- */
45632
- function resolveCellStyle(cell) {
45633
- const inverse = cell.isInverse() !== 0;
45634
- const plainFg = resolveForeground(cell);
45635
- const plainBg = resolveBackground(cell);
45636
- const background = inverse ? plainFg : plainBg;
45637
- let foreground = inverse ? plainBg : plainFg;
45638
- if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
45639
- if (cell.isInvisible() !== 0) foreground = background;
45640
- return {
45641
- fg: foreground === "#d7dce2" ? null : foreground,
45642
- bg: background === "#0b0d10" ? null : background,
45643
- bold: cell.isBold() !== 0
45644
- };
45645
- }
45646
- function sameStyle(left, right) {
45647
- return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
45648
- }
45649
- /**
45650
- * Merge adjacent same-style cells into runs, then drop the trailing run of
45651
- * default-styled whitespace so a row costs what it draws — the same trim
45652
- * `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
45653
- * a green bar of spaces out to the right margin is a pixel Glances drew.
45654
- */
45655
- function buildCellRuns(cells) {
45656
- const runs = [];
45657
- let text = "";
45658
- let style = null;
45659
- for (const cell of cells) {
45660
- if (style !== null && sameStyle(style, cell.style)) {
45661
- text += cell.text;
45662
- continue;
45663
- }
45664
- if (style !== null) runs.push({
45665
- text,
45666
- ...style
45667
- });
45668
- text = cell.text;
45669
- style = cell.style;
45670
- }
45671
- if (style !== null) runs.push({
45672
- text,
45673
- ...style
45674
- });
45675
- while (runs.length > 0) {
45676
- const last = runs[runs.length - 1];
45677
- if (last === void 0 || last.bg !== null) break;
45678
- const trimmed = last.text.replace(/\s+$/u, "");
45679
- if (trimmed === last.text) break;
45680
- if (trimmed === "") {
45681
- runs.pop();
45682
- continue;
45683
- }
45684
- runs[runs.length - 1] = {
45685
- ...last,
45686
- text: trimmed
45687
- };
45688
- break;
45689
- }
45690
- return runs;
45691
- }
45692
- //#endregion
45693
- //#region src/xterm-screen.ts
45694
- /**
45695
- * Real {@link ScreenBuffer} backed by `@xterm/headless` — the DOM-less xterm
45696
- * build — plus the serialize addon, which turns the current buffer into a
45697
- * self-contained repaint escape sequence for reconnecting clients.
45698
- */
45699
46233
  var SCROLLBACK_LINES = 2e3;
45700
46234
  function createXtermScreen(cols, rows) {
45701
46235
  const term = new import_xterm_headless.Terminal({
@@ -45762,196 +46296,6 @@ function createXtermScreen(cols, rows) {
45762
46296
  };
45763
46297
  }
45764
46298
  //#endregion
45765
- //#region src/terminal-camera-declarations.ts
45766
- /**
45767
- * Feed DeclaredDevices every live declaration plus one deterministic orphan
45768
- * batch. The generic sweep intentionally refuses an over-limit set; selecting
45769
- * a batch here drains large historical Terminal orphan sets across convergence
45770
- * passes without weakening that global safety guard.
45771
- */
45772
- function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
45773
- if (!integrationId) return [];
45774
- const declared = new Set(declarations.map((camera) => camera.stableId));
45775
- const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
45776
- return [...owned.filter((row) => declared.has(row.stableId)), ...owned.filter((row) => !declared.has(row.stableId)).sort((left, right) => left.id - right.id).slice(0, 32)];
45777
- }
45778
- /** Explicit persisted instances, never the node × profile template matrix. */
45779
- function buildTerminalInstanceCameraDeclarations(instances) {
45780
- return instances.filter((instance) => instance.enabled).map((instance) => ({
45781
- stableId: instance.cameraStableId,
45782
- name: instance.name,
45783
- config: {
45784
- instanceId: instance.id,
45785
- nodeId: instance.nodeId,
45786
- profileId: instance.profileId,
45787
- profileLabel: instance.profileLabel
45788
- }
45789
- }));
45790
- }
45791
- /**
45792
- * `DeviceConfig` materializes schema defaults in memory, so comparing
45793
- * `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
45794
- * inspect the raw persisted blob to make the profile migration durable.
45795
- */
45796
- function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
45797
- return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
45798
- }
45799
- /**
45800
- * Monospace families to try, in order — NOT one family and a generic.
45801
- *
45802
- * A terminal screen is mostly box-drawing and block characters, and a font
45803
- * without them renders the frame as noise rather than as missing detail.
45804
- * `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
45805
- * DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
45806
- * coverage is not, and its Glances camera came out unreadable while the hub's
45807
- * was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
45808
- *
45809
- * `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
45810
- * present on every install, and derived from DejaVu Sans Mono — the same glyph
45811
- * coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
45812
- * generic stays last so a host with none of them still draws something.
45813
- */
45814
- var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
45815
- var TERMINAL_FONT_SIZE = 13;
45816
- var TERMINAL_TEXT_MARGIN_X = 8;
45817
- var TERMINAL_ROW_HEIGHT = 15;
45818
- var TERMINAL_BASELINE_Y = 18;
45819
- /**
45820
- * Distance from a row's baseline up to the top of its cell box. Chosen so
45821
- * consecutive rows tile exactly: row N's box runs from `baseline - this` for
45822
- * `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
45823
- * bar that stopped short would draw as stripes across a `CSI 42m` panel.
45824
- */
45825
- var TERMINAL_CELL_ASCENT = 11.5;
45826
- var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
45827
- /** Two decimals is under a tenth of a pixel and keeps the SVG small. */
45828
- function coordinate(value) {
45829
- return String(Number(value.toFixed(2)));
45830
- }
45831
- function escapeXml(value) {
45832
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
45833
- }
45834
- /**
45835
- * Render already-interpreted terminal rows into a compact MJPEG frame.
45836
- *
45837
- * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
45838
- * runs of whitespace by default, and a terminal's entire column alignment IS
45839
- * runs of whitespace — Glances pads every field with spaces. Without it the
45840
- * frame drew each line at roughly half its true width, crammed into the
45841
- * top-left of a mostly-black image, while the SAME session over `attach`
45842
- * looked perfect — which is exactly how the operator reported it. Measured in
45843
- * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
45844
- * collapsed against 178 px preserved.
45845
- *
45846
- * Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
45847
- * never appended to the one before it, so the background rects and the glyphs
45848
- * are placed off the same grid and cannot drift apart. `textLength` is emitted
45849
- * with it because it is the correct declaration and renderers that honour it
45850
- * get an exact grid — but it is not what makes this work: librsvg, which sharp
45851
- * uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
45852
- * 600 px still drew its natural 937 px). The anchoring is the guarantee.
45853
- */
45854
- function renderTerminalSvg(rows) {
45855
- const backgrounds = [];
45856
- const texts = [];
45857
- rows.slice(0, 40).forEach((row, index) => {
45858
- const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
45859
- const top = baseline - TERMINAL_CELL_ASCENT;
45860
- let column = 0;
45861
- for (const run of row) {
45862
- if (column >= 120) break;
45863
- const clipped = clipRun(run, 120 - column);
45864
- const columns = [...clipped].length;
45865
- if (columns === 0) continue;
45866
- const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
45867
- const width = columns * TERMINAL_CELL_WIDTH;
45868
- if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
45869
- if (clipped.trim() !== "") {
45870
- const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
45871
- const weight = run.bold ? " font-weight=\"bold\"" : "";
45872
- texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
45873
- }
45874
- column += columns;
45875
- }
45876
- });
45877
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="${TERMINAL_DEFAULT_BG}"/>${backgrounds.join("")}<g fill="${TERMINAL_DEFAULT_FG}" font-family="${TERMINAL_FONT_STACK}" font-size="${String(TERMINAL_FONT_SIZE)}">${texts.join("")}</g></svg>`;
45878
- }
45879
- /** Cut a run to the columns still left in the row, by code point not unit. */
45880
- function clipRun(run, remaining) {
45881
- const points = [...run.text];
45882
- return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
45883
- }
45884
- async function renderTerminalJpeg(rows) {
45885
- return sharp(Buffer.from(renderTerminalSvg(rows))).jpeg({
45886
- quality: 82,
45887
- chromaSubsampling: "4:2:0"
45888
- }).toBuffer();
45889
- }
45890
- //#endregion
45891
- //#region src/terminal-camera-device.ts
45892
- var terminalCameraSchema = object({
45893
- instanceId: string().min(1).optional(),
45894
- nodeId: string().min(1),
45895
- profileId: string().min(1).default("monitor"),
45896
- profileLabel: string().min(1).default("BTM")
45897
- });
45898
- var relay = null;
45899
- function installTerminalCameraRelay(next) {
45900
- relay = next;
45901
- }
45902
- var TerminalCameraDevice = class extends BaseDevice {
45903
- features = [DeviceFeature.NativeSnapshot];
45904
- constructor(ctx) {
45905
- super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
45906
- this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
45907
- if (deviceId !== this.id) return [];
45908
- return this.catalog();
45909
- } });
45910
- this.ctx.registerNativeCap(snapshotCapability, {
45911
- getSnapshot: async ({ deviceId }) => {
45912
- if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
45913
- const activeRelay = relay;
45914
- if (!activeRelay) throw new Error("terminal camera relay is unavailable");
45915
- return {
45916
- base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
45917
- contentType: "image/jpeg"
45918
- };
45919
- },
45920
- invalidateCache: async () => {}
45921
- });
45922
- this.markOnline(true);
45923
- }
45924
- async catalog() {
45925
- const activeRelay = relay;
45926
- if (!activeRelay) throw new Error("terminal camera relay is unavailable");
45927
- const nodeId = this.config.get("nodeId");
45928
- const profileId = this.config.get("profileId");
45929
- const instanceId = this.relayInstanceId();
45930
- return [{
45931
- camStreamId: profileId,
45932
- kind: "pull-http",
45933
- url: activeRelay.streamUrl(instanceId, nodeId, profileId),
45934
- codec: "h264",
45935
- resolution: {
45936
- width: 960,
45937
- height: 640
45938
- },
45939
- fps: 2,
45940
- label: this.config.get("profileLabel")
45941
- }];
45942
- }
45943
- setNodeOnline(online) {
45944
- this.markOnline(online);
45945
- if (!online) relay?.closeInstance(this.relayInstanceId());
45946
- }
45947
- async removeDevice() {
45948
- await relay?.closeInstance(this.relayInstanceId());
45949
- }
45950
- relayInstanceId() {
45951
- return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
45952
- }
45953
- };
45954
- //#endregion
45955
46299
  //#region src/terminal-camera-relay.ts
45956
46300
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
45957
46301
  var SESSION_IDLE_MS = 3e4;
@@ -46502,13 +46846,34 @@ function newTerminalCameraStableId(instanceId) {
46502
46846
  * Legacy automatic cameras are migration candidates only. A tombstone is
46503
46847
  * durable deletion intent, so a lingering failed device removal must never
46504
46848
  * make that camera adoptable again.
46849
+ *
46850
+ * ## `config` is load-bearing — this read can never be `projection: 'slim'`
46851
+ *
46852
+ * `nodeId`, `profileId` and `profileLabel` all live in the device's `config`,
46853
+ * and a row without `nodeId` is skipped. The slim projection returns
46854
+ * `config: {}` for every row, so a slim answer here is shape-identical to a
46855
+ * fleet that has no legacy cameras — the whole migration section disappears
46856
+ * and nothing says why. `legacy-camera-read-shape.spec.ts` is the arm on that.
46857
+ *
46858
+ * `onSkipped` is why the disappearance would now be visible: a row that LOOKS
46859
+ * like a legacy camera (`terminal-camera-*`, not an instance camera, not
46860
+ * tombstoned, not already adopted) but carries no `nodeId` is reported with
46861
+ * its numeric device id, so the caller can log it per-camera. Rows that are
46862
+ * not candidates at all are silent — a fleet of 1 017 devices must not
46863
+ * produce 1 017 lines to say none of them is a Terminal monitor.
46505
46864
  */
46506
- function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
46865
+ function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones, onSkipped) {
46507
46866
  const legacy = [];
46508
46867
  for (const row of rows) {
46509
46868
  if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
46510
46869
  const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
46511
- if (!nodeId) continue;
46870
+ if (!nodeId) {
46871
+ onSkipped?.({
46872
+ deviceId: row.id,
46873
+ stableId: row.stableId
46874
+ });
46875
+ continue;
46876
+ }
46512
46877
  const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
46513
46878
  const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
46514
46879
  legacy.push({
@@ -46669,111 +47034,6 @@ function findProfile(profiles, profileId) {
46669
47034
  return profiles.find((p) => p.profileId === profileId);
46670
47035
  }
46671
47036
  //#endregion
46672
- //#region src/profile-settings.ts
46673
- var GLANCES_PLUGINS = [
46674
- {
46675
- key: "showCpu",
46676
- plugin: "cpu",
46677
- label: "CPU"
46678
- },
46679
- {
46680
- key: "showMem",
46681
- plugin: "mem",
46682
- label: "Memory"
46683
- },
46684
- {
46685
- key: "showLoad",
46686
- plugin: "load",
46687
- label: "Load"
46688
- },
46689
- {
46690
- key: "showNetwork",
46691
- plugin: "network",
46692
- label: "Network"
46693
- },
46694
- {
46695
- key: "showDiskIo",
46696
- plugin: "diskio",
46697
- label: "Disk I/O"
46698
- },
46699
- {
46700
- key: "showFs",
46701
- plugin: "fs",
46702
- label: "Filesystems"
46703
- },
46704
- {
46705
- key: "showProcessList",
46706
- plugin: "processlist",
46707
- label: "Process list"
46708
- },
46709
- {
46710
- key: "showContainers",
46711
- plugin: "containers",
46712
- label: "Containers"
46713
- },
46714
- {
46715
- key: "showSensors",
46716
- plugin: "sensors",
46717
- label: "Sensors"
46718
- }
46719
- ];
46720
- function glancesBooleanField(key, label) {
46721
- return {
46722
- type: "boolean",
46723
- key,
46724
- label,
46725
- default: true,
46726
- style: "switch"
46727
- };
46728
- }
46729
- function glancesSettingsSchema() {
46730
- return { sections: [{
46731
- id: "glances-panels",
46732
- title: "Glances panels",
46733
- description: "Turn off a panel to pass --disable-plugin to this Terminal only. All on is the measured default (~1% of one core at the camera grid).",
46734
- columns: 2,
46735
- fields: GLANCES_PLUGINS.map((plugin) => glancesBooleanField(plugin.key, plugin.label))
46736
- }] };
46737
- }
46738
- function settingsSchemaForProfile(profileId) {
46739
- if (profileId === "glances") return glancesSettingsSchema();
46740
- return null;
46741
- }
46742
- function glancesSettingsToArgs(settings) {
46743
- const disabled = GLANCES_PLUGINS.filter((plugin) => settings[plugin.key] === false).map((plugin) => plugin.plugin);
46744
- if (disabled.length === 0) return [];
46745
- return ["--disable-plugin", disabled.join(",")];
46746
- }
46747
- function sanitizeProfileSettings(profileId, raw) {
46748
- if (profileId !== "glances") return {};
46749
- const bag = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
46750
- const out = {
46751
- showCpu: true,
46752
- showMem: true,
46753
- showLoad: true,
46754
- showNetwork: true,
46755
- showDiskIo: true,
46756
- showFs: true,
46757
- showProcessList: true,
46758
- showContainers: true,
46759
- showSensors: true
46760
- };
46761
- for (const plugin of GLANCES_PLUGINS) {
46762
- const value = bag[plugin.key];
46763
- if (typeof value === "boolean") out[plugin.key] = value;
46764
- }
46765
- return out;
46766
- }
46767
- function profileSettingsToArgs(profileId, settings) {
46768
- if (profileId !== "glances") return [];
46769
- return glancesSettingsToArgs(sanitizeProfileSettings(profileId, settings));
46770
- }
46771
- function spawnArgsForInstance(input) {
46772
- const extra = profileSettingsToArgs(input.profileId, input.profileSettings);
46773
- if (input.instanceArgs.length > 0) return [...input.instanceArgs, ...extra];
46774
- if (extra.length > 0) return [...input.profileArgs, ...extra];
46775
- }
46776
- //#endregion
46777
47037
  //#region src/terminal-session-manager.ts
46778
47038
  var MIN_GRID = 1;
46779
47039
  var MAX_COLS = 1e3;
@@ -47595,7 +47855,12 @@ var TerminalAddon = class extends BaseAddon {
47595
47855
  async listLegacyTerminalCameras() {
47596
47856
  const instances = this.terminalInstances();
47597
47857
  const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
47598
- return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
47858
+ return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones, ({ deviceId, stableId }) => {
47859
+ this.ctx.logger.warn("legacy Terminal camera skipped — its device config carries no nodeId, so it cannot be offered for adoption", {
47860
+ tags: { deviceId },
47861
+ meta: { stableId }
47862
+ });
47863
+ });
47599
47864
  }
47600
47865
  async adoptLegacyMonitor(stableId, requestedName) {
47601
47866
  const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));