@camstack/addon-terminal 0.1.10 → 0.1.12

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
@@ -7627,12 +7627,11 @@ var RecordingConfigSchema = object({
7627
7627
  /**
7628
7628
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7629
7629
  *
7630
- * One shape shared by the recorder's `relocateFootage` (segments) and
7631
- * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7632
- * page renders both movers with one component. Jobs are in-RAM (a restart
7633
- * forgets them re-running is safe by construction: copy-if-absent, delete
7634
- * after verify) and each completed/failed run also lands one durable ops-log
7635
- * row on the owning addon's surface.
7630
+ * One shape shared by the recorder and pipeline-analytics internal movers.
7631
+ * The public admin surface is `storage-migration`; child jobs remain in RAM
7632
+ * because copy-if-absent, verify, delete and index/row repoint are resumable.
7633
+ * Each completed/failed run also lands one durable ops-log row on its owning
7634
+ * addon surface.
7636
7635
  */
7637
7636
  var RelocateJobStateSchema = _enum([
7638
7637
  "running",
@@ -7659,19 +7658,100 @@ var RelocateJobSchema = object({
7659
7658
  finishedAt: number().nullable(),
7660
7659
  error: string().nullable()
7661
7660
  });
7661
+ /** Profile-derived footage selection used only by the migration coordinator:
7662
+ * `recordings` owns high+mid; `recordingsLow` owns low. */
7663
+ var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
7662
7664
  var RelocateFootageInputSchema = object({
7663
- deviceId: number().optional(),
7664
7665
  fromLocationId: string(),
7665
7666
  toLocationId: string(),
7666
7667
  entities: array(_enum(["segments"])).optional(),
7668
+ /** Limits relocation to the logical profile class. Omit only for the
7669
+ * pre-orchestration compatibility path. */
7670
+ footageClass: RelocateFootageClassSchema.optional(),
7667
7671
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7668
7672
  * never allowed to starve live writers. */
7669
7673
  throttleMbps: number().min(1).max(1e3).optional()
7670
7674
  });
7671
- var RelocateMediaInputSchema = object({
7672
- deviceId: number().optional(),
7675
+ /** Internal, lease-scoped participant operation. It is intentionally separate
7676
+ * from persistent recording settings: a migration never changes
7677
+ * `RecordingConfig.enabled` or camera wrapper bindings. */
7678
+ var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
7679
+ var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
7680
+ var StorageMigrationMediaMoveInputSchema = object({
7673
7681
  toLocationId: string(),
7674
7682
  throttleMbps: number().min(1).max(1e3).optional()
7683
+ }).extend({ leaseId: string().min(1) });
7684
+ /** The independently selectable logical storage classes. `recordings`
7685
+ * encompasses the high and mid segment profiles; `recordingsLow` is low
7686
+ * segments; `eventMedia` is post-analysis blobs. */
7687
+ var StorageMigrationClassSchema = _enum([
7688
+ "recordings",
7689
+ "recordingsLow",
7690
+ "eventMedia"
7691
+ ]);
7692
+ /** A destination is always an existing, fully-qualified location id. The
7693
+ * migration API intentionally never changes a source location's `basePath`:
7694
+ * callers create a new `<type>:<slug>` location, then select it here. */
7695
+ var StorageMigrationDestinationsSchema = object({
7696
+ recordings: string().min(1).optional(),
7697
+ recordingsLow: string().min(1).optional(),
7698
+ eventMedia: string().min(1).optional()
7699
+ }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
7700
+ /** Shared input for planning and starting an orchestrated storage migration. */
7701
+ var StorageMigrationInputSchema = object({
7702
+ destinations: StorageMigrationDestinationsSchema,
7703
+ throttleMbps: number().min(1).max(1e3).optional()
7704
+ });
7705
+ /** The durable coordinator state machine. The only phase that changes default
7706
+ * locations is `repointing`, after every selected mover has completed and been
7707
+ * verified. */
7708
+ var StorageMigrationPhaseSchema = _enum([
7709
+ "planning",
7710
+ "pausing",
7711
+ "moving",
7712
+ "verifying",
7713
+ "repointing",
7714
+ "refreshing",
7715
+ "resuming",
7716
+ "done",
7717
+ "failed",
7718
+ "cancelled"
7719
+ ]);
7720
+ var StorageMigrationParticipantSchema = _enum([
7721
+ "pipeline",
7722
+ "recorder",
7723
+ "analytics"
7724
+ ]);
7725
+ var StorageMigrationMoveSchema = object({
7726
+ storageClass: StorageMigrationClassSchema,
7727
+ fromLocationId: string(),
7728
+ toLocationId: string(),
7729
+ moverJobId: string().nullable(),
7730
+ state: RelocateJobStateSchema.nullable(),
7731
+ error: string().nullable()
7732
+ });
7733
+ var StorageMigrationJobSchema = object({
7734
+ jobId: string(),
7735
+ phase: StorageMigrationPhaseSchema,
7736
+ destinations: StorageMigrationDestinationsSchema,
7737
+ throttleMbps: number(),
7738
+ moves: array(StorageMigrationMoveSchema),
7739
+ pauseLeaseId: string().nullable(),
7740
+ pausedParticipants: array(StorageMigrationParticipantSchema),
7741
+ repointed: boolean(),
7742
+ cancelRequested: boolean(),
7743
+ startedAt: number(),
7744
+ updatedAt: number(),
7745
+ finishedAt: number().nullable(),
7746
+ error: string().nullable()
7747
+ });
7748
+ var StorageMigrationPlanSchema = object({
7749
+ destinations: StorageMigrationDestinationsSchema,
7750
+ moves: array(object({
7751
+ storageClass: StorageMigrationClassSchema,
7752
+ fromLocationId: string(),
7753
+ toLocationId: string()
7754
+ }))
7675
7755
  });
7676
7756
  /**
7677
7757
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -16270,13 +16350,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
16270
16350
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
16271
16351
  kind: "mutation",
16272
16352
  auth: "admin"
16273
- }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
16353
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
16274
16354
  kind: "mutation",
16275
16355
  auth: "admin"
16276
- }), method(object({}), array(RelocateJobSchema).readonly(), {
16277
- kind: "query",
16356
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
16357
+ kind: "mutation",
16358
+ auth: "admin"
16359
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
16360
+ kind: "mutation",
16278
16361
  auth: "admin"
16279
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16362
+ }), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
16363
+ kind: "mutation",
16364
+ auth: "admin"
16365
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16280
16366
  kind: "mutation",
16281
16367
  auth: "admin"
16282
16368
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
@@ -17806,7 +17892,13 @@ var NodeInferenceDevicesSchema = object({
17806
17892
  reachable: boolean(),
17807
17893
  devices: array(NodeInferenceDeviceSchema).readonly()
17808
17894
  });
17809
- method(object({
17895
+ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
17896
+ kind: "mutation",
17897
+ auth: "admin"
17898
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
17899
+ kind: "mutation",
17900
+ auth: "admin"
17901
+ }), method(object({
17810
17902
  deviceId: number(),
17811
17903
  agentNodeId: string()
17812
17904
  }), object({ success: literal(true) }), {
@@ -18246,7 +18338,33 @@ var SnapshotImageSchema = object({
18246
18338
  base64: string(),
18247
18339
  contentType: string()
18248
18340
  });
18249
- object({
18341
+ /**
18342
+ * snapshot — device-scoped capability for camera image capture.
18343
+ *
18344
+ * Two kinds of providers coexist behind this cap name:
18345
+ *
18346
+ * - **Native** providers (kind:'native'): registered per-device by
18347
+ * device-driver addons (RtspCamera, OnvifCamera, …) via
18348
+ * `DeviceContext.registerNativeCap`. Each knows how to fetch a frame
18349
+ * straight from the camera (HTTP snapshot URL, ONVIF action, etc.).
18350
+ *
18351
+ * - **Wrapper** providers (kind:'wrapper'): register as a system
18352
+ * provider (SnapshotAddon in `@camstack/system/builtins/snapshot`). The
18353
+ * wrapper owns the cache and invokes the native via
18354
+ * `ctx.getNativeProvider(snapshotCapability, deviceId)` on miss.
18355
+ *
18356
+ * Device-scoped routing: callers use `ctx.fetchDevice(id).snapshot.*`;
18357
+ * the DeviceProxy auto-injects `deviceId` + `nodeId` and dispatches to
18358
+ * the provider currently active for that device (wrapper wins when
18359
+ * activated via `setWrapperActive`, otherwise the native).
18360
+ */
18361
+ /**
18362
+ * Live readable snapshot state — diagnostic info that a consumer can
18363
+ * pull to know when the last image was captured, how stale the cache
18364
+ * is, and which stream was used. Distinct from `getSnapshot` which
18365
+ * returns the JPEG itself.
18366
+ */
18367
+ var SnapshotStatusSchema = object({
18250
18368
  /** Ms epoch of the last successful capture. Null if none yet. */
18251
18369
  lastCapturedAt: number().nullable(),
18252
18370
  /** Age of the cached image in ms. Null if no cache. */
@@ -18256,64 +18374,129 @@ object({
18256
18374
  /** Stream id used for the last capture ('high'|'mid'|'low' or custom). Null if via HTTP endpoint. */
18257
18375
  lastStreamId: string().nullable()
18258
18376
  });
18259
- DeviceType.Camera, method(object({
18260
- deviceId: number(),
18261
- streamId: string().optional(),
18262
- /**
18263
- * Bypass the cache freshness check and fetch directly from the
18264
- * native (or stream-broker fallback). Triggered by the UI's
18265
- * "refresh" button so an operator can force a fresh frame
18266
- * even when the cache is well within the device's
18267
- * `snapshotMaxAgeS` window.
18268
- *
18269
- * **`force` is an OPERATOR signal, not a freshness preference.** On a
18270
- * battery camera it is the one thing that walks past the wrapper's
18271
- * sleep gate and wakes the camera, so a background caller — a poller,
18272
- * an event handler, a thumbnail — must NEVER set it. Every such caller
18273
- * gets the cached frame, which on a sleeping battery camera is the
18274
- * correct answer: stale but honest beats woken.
18275
- */
18276
- force: boolean().optional()
18277
- }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
18278
- kind: "mutation",
18279
- auth: "admin"
18280
- }), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
18281
- deviceId: number(),
18282
- lastCapturedAt: number().nullable(),
18283
- cacheAgeMs: number().nullable(),
18284
- etag: string().nullable()
18285
- }))), systemMethod(object({
18286
- /** The tiles a surface is actually rendering. One entry per (device,
18287
- * width) the caller will paint — the width is snapped to the server's
18288
- * ladder and becomes part of the link's SIGNED identity. */
18377
+ var snapshotCapability = {
18378
+ name: "snapshot",
18379
+ scope: "device",
18380
+ deviceNative: true,
18381
+ mode: "singleton",
18382
+ kind: "wrapper",
18383
+ defaultActive: true,
18384
+ deviceTypes: [DeviceType.Camera],
18385
+ exposesDeviceSettings: true,
18386
+ methods: {
18387
+ getSnapshot: method(object({
18388
+ deviceId: number(),
18389
+ streamId: string().optional(),
18390
+ /**
18391
+ * Bypass the cache freshness check and fetch directly from the
18392
+ * native (or stream-broker fallback). Triggered by the UI's
18393
+ * "refresh" button so an operator can force a fresh frame
18394
+ * even when the cache is well within the device's
18395
+ * `snapshotMaxAgeS` window.
18396
+ *
18397
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
18398
+ * battery camera it is the one thing that walks past the wrapper's
18399
+ * sleep gate and wakes the camera, so a background caller — a poller,
18400
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
18401
+ * gets the cached frame, which on a sleeping battery camera is the
18402
+ * correct answer: stale but honest beats woken.
18403
+ */
18404
+ force: boolean().optional()
18405
+ }), SnapshotImageSchema.nullable()),
18406
+ invalidateCache: method(object({ deviceId: number() }), _void(), {
18407
+ kind: "mutation",
18408
+ auth: "admin"
18409
+ }),
18410
+ /**
18411
+ * Cache-only batch overview — answers from the wrapper's in-memory cache in
18412
+ * O(n) and NEVER triggers a capture. Lets a grid skip requesting images for
18413
+ * devices that never produced a frame, and gives it an ETag per device for
18414
+ * conditional (304-able) image fetches. `lastCapturedAt`/`cacheAgeMs`/`etag`
18415
+ * are null for a device with no cached frame.
18416
+ */
18417
+ getSnapshotOverview: systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
18418
+ deviceId: number(),
18419
+ lastCapturedAt: number().nullable(),
18420
+ cacheAgeMs: number().nullable(),
18421
+ etag: string().nullable()
18422
+ }))),
18423
+ /**
18424
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
18425
+ * that makes those frames current.
18426
+ *
18427
+ * ## The problem it replaces
18428
+ *
18429
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
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.
18439
+ *
18440
+ * ## The two properties that fix it
18441
+ *
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.
18449
+ *
18450
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
18451
+ * returned `capturedAt` is the frame the link will serve, not the frame the
18452
+ * cache held when the client asked, so a first paint is honest and current
18453
+ * instead of a generation behind. A device that does not settle inside the
18454
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
18455
+ * carries it forward.
18456
+ *
18457
+ * `force` is never set on behalf of a client here. A sleeping battery camera
18458
+ * is reported with `sleeping: true` and the last frame it produced, however
18459
+ * old; the wrapper's existing sleep gate owns that decision and this method
18460
+ * adds no second one.
18461
+ */
18462
+ getSnapshotLinks: systemMethod(object({
18463
+ /** The tiles a surface is actually rendering. One entry per (device,
18464
+ * width) the caller will paint — the width is snapped to the server's
18465
+ * ladder and becomes part of the link's SIGNED identity. */
18289
18466
  targets: array(object({
18290
- deviceId: number(),
18291
- /** Target width in px. Omit for the frame as captured — correct
18292
- * for a full-bleed surface, wrong (and expensive) for a grid. */
18293
- width: number().int().positive().optional()
18294
- })).min(1).max(200) }), array(object({
18295
- deviceId: number(),
18296
- /** Root-relative signed path, or null when the link plane is not
18297
- * served (no data-plane facility). Present even for a device that has
18298
- * never captured — the request is what triggers the first one (D94). */
18299
- url: string().nullable(),
18300
- /** Epoch ms of the frame this link serves. Null = never captured.
18301
- * THE honest age: the tRPC path carried none before this. */
18302
- capturedAt: number().nullable(),
18303
- /** Age of that frame at the moment the answer was built. */
18304
- ageMs: number().nullable(),
18305
- /** Epoch ms after which `url` stops verifying. */
18306
- expiresAt: number().nullable(),
18307
- /** Ladder rung the bytes are at; null = the frame as captured. */
18308
- width: number().nullable(),
18309
- /** The device has never produced a frame. An empty state, not a
18310
- * failure — and never a reason to withhold the link (D94). */
18311
- neverCaptured: boolean(),
18312
- /** A sleeping battery camera: the frame is deliberately stale and will
18313
- * NOT refresh in the background. A surface should say so rather than
18314
- * present it as current. */
18315
- sleeping: boolean()
18316
- })));
18467
+ deviceId: number(),
18468
+ /** Target width in px. Omit for the frame as captured — correct
18469
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18470
+ width: number().int().positive().optional()
18471
+ })).min(1).max(200) }), array(object({
18472
+ deviceId: number(),
18473
+ /** Root-relative signed path, or null when the link plane is not
18474
+ * served (no data-plane facility). Present even for a device that has
18475
+ * never captured — the request is what triggers the first one (D94). */
18476
+ url: string().nullable(),
18477
+ /** Epoch ms of the frame this link serves. Null = never captured.
18478
+ * THE honest age: the tRPC path carried none before this. */
18479
+ capturedAt: number().nullable(),
18480
+ /** Age of that frame at the moment the answer was built. */
18481
+ ageMs: number().nullable(),
18482
+ /** Epoch ms after which `url` stops verifying. */
18483
+ expiresAt: number().nullable(),
18484
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18485
+ width: number().nullable(),
18486
+ /** The device has never produced a frame. An empty state, not a
18487
+ * failure — and never a reason to withhold the link (D94). */
18488
+ neverCaptured: boolean(),
18489
+ /** A sleeping battery camera: the frame is deliberately stale and will
18490
+ * NOT refresh in the background. A surface should say so rather than
18491
+ * present it as current. */
18492
+ sleeping: boolean()
18493
+ })))
18494
+ },
18495
+ status: {
18496
+ schema: SnapshotStatusSchema,
18497
+ kind: "poll"
18498
+ }
18499
+ };
18317
18500
  /**
18318
18501
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
18319
18502
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -18480,6 +18663,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
18480
18663
  locationId: string(),
18481
18664
  targetBytes: number().int().positive()
18482
18665
  }), EvictResultSchema, { kind: "mutation" });
18666
+ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
18667
+ kind: "mutation",
18668
+ auth: "admin"
18669
+ }), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
18670
+ kind: "mutation",
18671
+ auth: "admin"
18672
+ });
18483
18673
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18484
18674
  providerId: string().min(1),
18485
18675
  displayName: string().min(1),
@@ -18583,6 +18773,28 @@ var TerminalProfileInfoSchema = object({
18583
18773
  label: string(),
18584
18774
  description: string().optional()
18585
18775
  });
18776
+ /**
18777
+ * A durable operator-created Terminal instance. Profiles are templates; only
18778
+ * an instance declares a camera.
18779
+ */
18780
+ var TerminalInstanceInfoSchema = object({
18781
+ instanceId: string(),
18782
+ cameraStableId: string(),
18783
+ nodeId: string(),
18784
+ profileId: string(),
18785
+ profileLabel: string(),
18786
+ name: string(),
18787
+ enabled: boolean()
18788
+ });
18789
+ var TerminalLegacyCameraSchema = object({
18790
+ stableId: string(),
18791
+ nodeId: string(),
18792
+ profileId: string(),
18793
+ profileLabel: string(),
18794
+ name: string(),
18795
+ /** Only legacy monitor cameras can retain their historic stable identity. */
18796
+ adoptable: boolean()
18797
+ });
18586
18798
  var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
18587
18799
  seq: number().int().positive(),
18588
18800
  kind: literal("data"),
@@ -18602,10 +18814,9 @@ var TerminalOutputBatchSchema = object({
18602
18814
  /**
18603
18815
  * terminal-session — singleton system capability for interactive TTY sessions.
18604
18816
  *
18605
- * Phase 1 (this cap): open/resize/close/list a pty running an allowlisted
18606
- * profile, streamed to xterm.js over the data plane. Phase 2 (streaming a
18607
- * session as a camera) is a separate device-provider concern and does not
18608
- * change this contract.
18817
+ * Owns both live PTY lifecycle and durable Terminal instance management.
18818
+ * Profiles are allowlisted templates; an explicit instance is the only path
18819
+ * that declares a camera.
18609
18820
  */
18610
18821
  var terminalSessionCapability = {
18611
18822
  name: "terminal-session",
@@ -18614,6 +18825,37 @@ var terminalSessionCapability = {
18614
18825
  methods: {
18615
18826
  /** Pre-declared profiles the operator may open. */
18616
18827
  listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
18828
+ /** Explicit durable Terminal instances, managed centrally on the hub. */
18829
+ listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
18830
+ createInstance: method(object({
18831
+ targetNodeId: string().min(1),
18832
+ profileId: string().min(1),
18833
+ name: string().trim().min(1).max(160).optional()
18834
+ }), TerminalInstanceInfoSchema, {
18835
+ kind: "mutation",
18836
+ auth: "admin"
18837
+ }),
18838
+ deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
18839
+ kind: "mutation",
18840
+ auth: "admin"
18841
+ }),
18842
+ setInstanceEnabled: method(object({
18843
+ instanceId: string().min(1),
18844
+ enabled: boolean()
18845
+ }), TerminalInstanceInfoSchema, {
18846
+ kind: "mutation",
18847
+ auth: "admin"
18848
+ }),
18849
+ /** Existing automatic cameras are shown for explicit migration only. */
18850
+ listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
18851
+ /** Explicitly adopt one legacy monitor camera, retaining its stable id. */
18852
+ adoptLegacyMonitor: method(object({
18853
+ stableId: string().min(1),
18854
+ name: string().trim().min(1).max(160).optional()
18855
+ }), TerminalInstanceInfoSchema, {
18856
+ kind: "mutation",
18857
+ auth: "admin"
18858
+ }),
18617
18859
  /** Live sessions currently hosted by the provider. */
18618
18860
  listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
18619
18861
  /**
@@ -18645,7 +18887,13 @@ var terminalSessionCapability = {
18645
18887
  pullOutput: method(object({
18646
18888
  sessionId: string(),
18647
18889
  afterSeq: number().int().nonnegative(),
18648
- waitMs: number().int().min(0).max(2e3).default(0)
18890
+ waitMs: number().int().min(0).max(2e3).default(0),
18891
+ /**
18892
+ * Wait when a just-opened session has no output yet. Kept opt-in so a
18893
+ * browser's initial repaint remains immediate; the camera snapshot
18894
+ * relay uses it to avoid encoding a blank startup frame.
18895
+ */
18896
+ waitForOutput: boolean().optional()
18649
18897
  }), TerminalOutputBatchSchema, {
18650
18898
  kind: "mutation",
18651
18899
  auth: "admin",
@@ -21149,6 +21397,7 @@ var FaceInfoSchema = object({
21149
21397
  var FaceFilterEnum = _enum([
21150
21398
  "unassigned",
21151
21399
  "recognized",
21400
+ "identified",
21152
21401
  "all"
21153
21402
  ]);
21154
21403
  var MediaFileLiteSchema$1 = object({
@@ -21177,6 +21426,8 @@ method(_void(), array(IdentitySchema).readonly()), method(object({ name: string(
21177
21426
  kind: "mutation",
21178
21427
  auth: "admin"
21179
21428
  }), method(object({
21429
+ /** Restrict to one camera. Absent keeps the cluster-wide gallery view. */
21430
+ deviceId: number().int().optional(),
21180
21431
  limit: number().int().positive().optional(),
21181
21432
  filter: FaceFilterEnum.optional(),
21182
21433
  /**
@@ -23406,6 +23657,10 @@ var OsdSourceSchema = discriminatedUnion("kind", [
23406
23657
  capName: string().min(1).max(64),
23407
23658
  /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
23408
23659
  valuePath: string().min(1).max(64)
23660
+ }),
23661
+ object({
23662
+ kind: literal("latest-recognition"),
23663
+ recognition: _enum(["person", "plate"])
23409
23664
  })
23410
23665
  ]);
23411
23666
  var OsdSlotBindingSchema = object({
@@ -23511,6 +23766,15 @@ method(object({ deviceId: number().int() }), object({
23511
23766
  }), object({ success: literal(true) }), {
23512
23767
  kind: "mutation",
23513
23768
  auth: "admin"
23769
+ }), method(object({
23770
+ sourceDeviceId: number().int(),
23771
+ targetDeviceId: number().int()
23772
+ }), object({
23773
+ copied: number().int().nonnegative(),
23774
+ skipped: number().int().nonnegative()
23775
+ }), {
23776
+ kind: "mutation",
23777
+ auth: "admin"
23514
23778
  }), method(object({
23515
23779
  deviceId: number().int(),
23516
23780
  slotId: string().min(1),
@@ -24608,13 +24872,19 @@ method(object({
24608
24872
  }), {
24609
24873
  kind: "mutation",
24610
24874
  auth: "admin"
24611
- }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24875
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
24612
24876
  kind: "mutation",
24613
24877
  auth: "admin"
24614
- }), method(object({}), array(RelocateJobSchema).readonly(), {
24615
- kind: "query",
24878
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
24879
+ kind: "mutation",
24880
+ auth: "admin"
24881
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
24882
+ kind: "mutation",
24616
24883
  auth: "admin"
24617
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24884
+ }), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
24885
+ kind: "mutation",
24886
+ auth: "admin"
24887
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24618
24888
  kind: "mutation",
24619
24889
  auth: "admin"
24620
24890
  });
@@ -30204,6 +30474,12 @@ Object.freeze({
30204
30474
  addonId: null,
30205
30475
  access: "delete"
30206
30476
  },
30477
+ "osdManager.copyDeviceConfiguration": {
30478
+ capName: "osd-manager",
30479
+ capScope: "system",
30480
+ addonId: null,
30481
+ access: "create"
30482
+ },
30207
30483
  "osdManager.getConditionSupport": {
30208
30484
  capName: "osd-manager",
30209
30485
  capScope: "system",
@@ -30300,7 +30576,7 @@ Object.freeze({
30300
30576
  addonId: null,
30301
30577
  access: "create"
30302
30578
  },
30303
- "pipelineAnalytics.cancelMediaRelocate": {
30579
+ "pipelineAnalytics.cancelStorageMigrationMove": {
30304
30580
  capName: "pipeline-analytics",
30305
30581
  capScope: "device",
30306
30582
  addonId: null,
@@ -30372,12 +30648,6 @@ Object.freeze({
30372
30648
  addonId: null,
30373
30649
  access: "view"
30374
30650
  },
30375
- "pipelineAnalytics.getMediaRelocateStatus": {
30376
- capName: "pipeline-analytics",
30377
- capScope: "device",
30378
- addonId: null,
30379
- access: "view"
30380
- },
30381
30651
  "pipelineAnalytics.getMotionEvents": {
30382
30652
  capName: "pipeline-analytics",
30383
30653
  capScope: "device",
@@ -30414,6 +30684,12 @@ Object.freeze({
30414
30684
  addonId: null,
30415
30685
  access: "view"
30416
30686
  },
30687
+ "pipelineAnalytics.getStorageMigrationMoveStatus": {
30688
+ capName: "pipeline-analytics",
30689
+ capScope: "device",
30690
+ addonId: null,
30691
+ access: "view"
30692
+ },
30417
30693
  "pipelineAnalytics.getTrack": {
30418
30694
  capName: "pipeline-analytics",
30419
30695
  capScope: "device",
@@ -30492,6 +30768,12 @@ Object.freeze({
30492
30768
  addonId: null,
30493
30769
  access: "view"
30494
30770
  },
30771
+ "pipelineAnalytics.pauseForStorageMigration": {
30772
+ capName: "pipeline-analytics",
30773
+ capScope: "device",
30774
+ addonId: null,
30775
+ access: "create"
30776
+ },
30495
30777
  "pipelineAnalytics.proposeRetrainAnnotations": {
30496
30778
  capName: "pipeline-analytics",
30497
30779
  capScope: "device",
@@ -30522,7 +30804,7 @@ Object.freeze({
30522
30804
  addonId: null,
30523
30805
  access: "create"
30524
30806
  },
30525
- "pipelineAnalytics.relocateMedia": {
30807
+ "pipelineAnalytics.refreshStorageLocationsForMigration": {
30526
30808
  capName: "pipeline-analytics",
30527
30809
  capScope: "device",
30528
30810
  addonId: null,
@@ -30534,6 +30816,12 @@ Object.freeze({
30534
30816
  addonId: null,
30535
30817
  access: "create"
30536
30818
  },
30819
+ "pipelineAnalytics.resumeForStorageMigration": {
30820
+ capName: "pipeline-analytics",
30821
+ capScope: "device",
30822
+ addonId: null,
30823
+ access: "create"
30824
+ },
30537
30825
  "pipelineAnalytics.saveRetrainAnnotations": {
30538
30826
  capName: "pipeline-analytics",
30539
30827
  capScope: "device",
@@ -30558,6 +30846,12 @@ Object.freeze({
30558
30846
  addonId: null,
30559
30847
  access: "create"
30560
30848
  },
30849
+ "pipelineAnalytics.startStorageMigrationMove": {
30850
+ capName: "pipeline-analytics",
30851
+ capScope: "device",
30852
+ addonId: null,
30853
+ access: "create"
30854
+ },
30561
30855
  "pipelineAnalytics.wipeAllAnalytics": {
30562
30856
  capName: "pipeline-analytics",
30563
30857
  capScope: "device",
@@ -30924,6 +31218,12 @@ Object.freeze({
30924
31218
  addonId: null,
30925
31219
  access: "view"
30926
31220
  },
31221
+ "pipelineOrchestrator.pauseForStorageMigration": {
31222
+ capName: "pipeline-orchestrator",
31223
+ capScope: "system",
31224
+ addonId: null,
31225
+ access: "create"
31226
+ },
30927
31227
  "pipelineOrchestrator.rebalance": {
30928
31228
  capName: "pipeline-orchestrator",
30929
31229
  capScope: "system",
@@ -30948,6 +31248,12 @@ Object.freeze({
30948
31248
  addonId: null,
30949
31249
  access: "view"
30950
31250
  },
31251
+ "pipelineOrchestrator.resumeForStorageMigration": {
31252
+ capName: "pipeline-orchestrator",
31253
+ capScope: "system",
31254
+ addonId: null,
31255
+ access: "create"
31256
+ },
30951
31257
  "pipelineOrchestrator.saveTemplate": {
30952
31258
  capName: "pipeline-orchestrator",
30953
31259
  capScope: "system",
@@ -31344,7 +31650,7 @@ Object.freeze({
31344
31650
  addonId: null,
31345
31651
  access: "create"
31346
31652
  },
31347
- "recording.cancelRelocate": {
31653
+ "recording.cancelStorageMigrationMove": {
31348
31654
  capName: "recording",
31349
31655
  capScope: "system",
31350
31656
  addonId: null,
@@ -31380,7 +31686,7 @@ Object.freeze({
31380
31686
  addonId: null,
31381
31687
  access: "view"
31382
31688
  },
31383
- "recording.getRelocateStatus": {
31689
+ "recording.getStorageMigrationMoveStatus": {
31384
31690
  capName: "recording",
31385
31691
  capScope: "system",
31386
31692
  addonId: null,
@@ -31404,6 +31710,12 @@ Object.freeze({
31404
31710
  addonId: null,
31405
31711
  access: "view"
31406
31712
  },
31713
+ "recording.pauseForStorageMigration": {
31714
+ capName: "recording",
31715
+ capScope: "system",
31716
+ addonId: null,
31717
+ access: "create"
31718
+ },
31407
31719
  "recording.pruneFootage": {
31408
31720
  capName: "recording",
31409
31721
  capScope: "system",
@@ -31422,7 +31734,7 @@ Object.freeze({
31422
31734
  addonId: null,
31423
31735
  access: "view"
31424
31736
  },
31425
- "recording.relocateFootage": {
31737
+ "recording.refreshStorageLocationsForMigration": {
31426
31738
  capName: "recording",
31427
31739
  capScope: "system",
31428
31740
  addonId: null,
@@ -31446,12 +31758,24 @@ Object.freeze({
31446
31758
  addonId: null,
31447
31759
  access: "create"
31448
31760
  },
31761
+ "recording.resumeForStorageMigration": {
31762
+ capName: "recording",
31763
+ capScope: "system",
31764
+ addonId: null,
31765
+ access: "create"
31766
+ },
31449
31767
  "recording.setDeviceConfig": {
31450
31768
  capName: "recording",
31451
31769
  capScope: "system",
31452
31770
  addonId: null,
31453
31771
  access: "create"
31454
31772
  },
31773
+ "recording.startStorageMigrationMove": {
31774
+ capName: "recording",
31775
+ capScope: "system",
31776
+ addonId: null,
31777
+ access: "create"
31778
+ },
31455
31779
  "recordingExport.cancelExport": {
31456
31780
  capName: "recordingExport",
31457
31781
  capScope: "system",
@@ -31842,6 +32166,30 @@ Object.freeze({
31842
32166
  addonId: null,
31843
32167
  access: "view"
31844
32168
  },
32169
+ "storageMigration.cancel": {
32170
+ capName: "storage-migration",
32171
+ capScope: "system",
32172
+ addonId: null,
32173
+ access: "create"
32174
+ },
32175
+ "storageMigration.plan": {
32176
+ capName: "storage-migration",
32177
+ capScope: "system",
32178
+ addonId: null,
32179
+ access: "view"
32180
+ },
32181
+ "storageMigration.start": {
32182
+ capName: "storage-migration",
32183
+ capScope: "system",
32184
+ addonId: null,
32185
+ access: "create"
32186
+ },
32187
+ "storageMigration.status": {
32188
+ capName: "storage-migration",
32189
+ capScope: "system",
32190
+ addonId: null,
32191
+ access: "view"
32192
+ },
31845
32193
  "storageProvider.abortUpload": {
31846
32194
  capName: "storage-provider",
31847
32195
  capScope: "system",
@@ -32220,12 +32568,42 @@ Object.freeze({
32220
32568
  addonId: null,
32221
32569
  access: "create"
32222
32570
  },
32571
+ "terminalSession.adoptLegacyMonitor": {
32572
+ capName: "terminal-session",
32573
+ capScope: "system",
32574
+ addonId: null,
32575
+ access: "create"
32576
+ },
32223
32577
  "terminalSession.close": {
32224
32578
  capName: "terminal-session",
32225
32579
  capScope: "system",
32226
32580
  addonId: null,
32227
32581
  access: "create"
32228
32582
  },
32583
+ "terminalSession.createInstance": {
32584
+ capName: "terminal-session",
32585
+ capScope: "system",
32586
+ addonId: null,
32587
+ access: "create"
32588
+ },
32589
+ "terminalSession.deleteInstance": {
32590
+ capName: "terminal-session",
32591
+ capScope: "system",
32592
+ addonId: null,
32593
+ access: "delete"
32594
+ },
32595
+ "terminalSession.listInstances": {
32596
+ capName: "terminal-session",
32597
+ capScope: "system",
32598
+ addonId: null,
32599
+ access: "view"
32600
+ },
32601
+ "terminalSession.listLegacyCameras": {
32602
+ capName: "terminal-session",
32603
+ capScope: "system",
32604
+ addonId: null,
32605
+ access: "view"
32606
+ },
32229
32607
  "terminalSession.listProfiles": {
32230
32608
  capName: "terminal-session",
32231
32609
  capScope: "system",
@@ -32256,6 +32634,12 @@ Object.freeze({
32256
32634
  addonId: null,
32257
32635
  access: "create"
32258
32636
  },
32637
+ "terminalSession.setInstanceEnabled": {
32638
+ capName: "terminal-session",
32639
+ capScope: "system",
32640
+ addonId: null,
32641
+ access: "create"
32642
+ },
32259
32643
  "terminalSession.writeInput": {
32260
32644
  capName: "terminal-session",
32261
32645
  capScope: "system",
@@ -33056,6 +33440,41 @@ function createNodePtySpawner() {
33056
33440
  async function warmNodePty() {
33057
33441
  await loadNodePty();
33058
33442
  }
33443
+ //#endregion
33444
+ //#region src/terminal-camera-declarations.ts
33445
+ /**
33446
+ * Feed DeclaredDevices every live declaration plus one deterministic orphan
33447
+ * batch. The generic sweep intentionally refuses an over-limit set; selecting
33448
+ * a batch here drains large historical Terminal orphan sets across convergence
33449
+ * passes without weakening that global safety guard.
33450
+ */
33451
+ function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
33452
+ if (!integrationId) return [];
33453
+ const declared = new Set(declarations.map((camera) => camera.stableId));
33454
+ const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
33455
+ 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)];
33456
+ }
33457
+ /** Explicit persisted instances, never the node × profile template matrix. */
33458
+ function buildTerminalInstanceCameraDeclarations(instances) {
33459
+ return instances.filter((instance) => instance.enabled).map((instance) => ({
33460
+ stableId: instance.cameraStableId,
33461
+ name: instance.name,
33462
+ config: {
33463
+ instanceId: instance.id,
33464
+ nodeId: instance.nodeId,
33465
+ profileId: instance.profileId,
33466
+ profileLabel: instance.profileLabel
33467
+ }
33468
+ }));
33469
+ }
33470
+ /**
33471
+ * `DeviceConfig` materializes schema defaults in memory, so comparing
33472
+ * `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
33473
+ * inspect the raw persisted blob to make the profile migration durable.
33474
+ */
33475
+ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33476
+ return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33477
+ }
33059
33478
  function escapeXml(value) {
33060
33479
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33061
33480
  }
@@ -33070,40 +33489,66 @@ async function renderTerminalJpeg(lines) {
33070
33489
  }
33071
33490
  //#endregion
33072
33491
  //#region src/terminal-camera-device.ts
33073
- var terminalCameraSchema = object({ nodeId: string().min(1) });
33492
+ var terminalCameraSchema = object({
33493
+ instanceId: string().min(1).optional(),
33494
+ nodeId: string().min(1),
33495
+ profileId: string().min(1).default("monitor"),
33496
+ profileLabel: string().min(1).default("BTM")
33497
+ });
33074
33498
  var relay = null;
33075
33499
  function installTerminalCameraRelay(next) {
33076
33500
  relay = next;
33077
33501
  }
33078
33502
  var TerminalCameraDevice = class extends BaseDevice {
33079
- features = [];
33503
+ features = [DeviceFeature.NativeSnapshot];
33080
33504
  constructor(ctx) {
33081
33505
  super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
33082
33506
  this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
33083
33507
  if (deviceId !== this.id) return [];
33084
33508
  return this.catalog();
33085
33509
  } });
33510
+ this.ctx.registerNativeCap(snapshotCapability, {
33511
+ getSnapshot: async ({ deviceId }) => {
33512
+ if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
33513
+ const activeRelay = relay;
33514
+ if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33515
+ return {
33516
+ base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
33517
+ contentType: "image/jpeg"
33518
+ };
33519
+ },
33520
+ invalidateCache: async () => {}
33521
+ });
33086
33522
  this.markOnline(true);
33087
33523
  }
33088
33524
  async catalog() {
33089
33525
  const activeRelay = relay;
33090
33526
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33091
33527
  const nodeId = this.config.get("nodeId");
33092
- return (await activeRelay.listProfiles(nodeId)).map((profile) => ({
33093
- camStreamId: profile.profileId,
33528
+ const profileId = this.config.get("profileId");
33529
+ const instanceId = this.relayInstanceId();
33530
+ return [{
33531
+ camStreamId: profileId,
33094
33532
  kind: "pull-http",
33095
- url: activeRelay.streamUrl(nodeId, profile.profileId),
33533
+ url: activeRelay.streamUrl(instanceId, nodeId, profileId),
33096
33534
  codec: "h264",
33097
33535
  resolution: {
33098
33536
  width: 960,
33099
33537
  height: 640
33100
33538
  },
33101
33539
  fps: 2,
33102
- label: profile.label
33103
- }));
33540
+ label: this.config.get("profileLabel")
33541
+ }];
33104
33542
  }
33105
33543
  setNodeOnline(online) {
33106
33544
  this.markOnline(online);
33545
+ if (!online) relay?.closeInstance(this.relayInstanceId());
33546
+ }
33547
+ async removeDevice() {
33548
+ await relay?.closeInstance(this.relayInstanceId());
33549
+ }
33550
+ relayInstanceId() {
33551
+ return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
33107
33552
  }
33108
33553
  };
33109
33554
  //#endregion
@@ -37952,18 +38397,24 @@ function createXtermScreen(cols, rows) {
37952
38397
  //#region src/terminal-camera-relay.ts
37953
38398
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
37954
38399
  var SESSION_IDLE_MS = 3e4;
37955
- function relayKey(nodeId, profileId) {
37956
- return `${nodeId}\0${profileId}`;
38400
+ var SNAPSHOT_STARTUP_WAIT_MS = 1500;
38401
+ var CLOSE_RETRY_BASE_MS = 50;
38402
+ var CLOSE_RETRY_MAX_MS = 1e3;
38403
+ var CLOSE_ATTEMPTS_PER_PASS = 3;
38404
+ function relayKey(instanceId) {
38405
+ return instanceId;
37957
38406
  }
37958
38407
  function parseStreamPath(url) {
37959
38408
  const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
37960
- if (parts.length !== 3 || parts[0] !== "stream") return null;
38409
+ if (parts.length !== 4 || parts[0] !== "stream") return null;
37961
38410
  try {
37962
- const nodeId = decodeURIComponent(parts[1] ?? "");
37963
- const profilePart = parts[2] ?? "";
38411
+ const instanceId = decodeURIComponent(parts[1] ?? "");
38412
+ const nodeId = decodeURIComponent(parts[2] ?? "");
38413
+ const profilePart = parts[3] ?? "";
37964
38414
  if (!profilePart.endsWith(".mjpeg")) return null;
37965
38415
  const profileId = decodeURIComponent(profilePart.slice(0, -6));
37966
- return nodeId && profileId ? {
38416
+ return instanceId && nodeId && profileId ? {
38417
+ instanceId,
37967
38418
  nodeId,
37968
38419
  profileId
37969
38420
  } : null;
@@ -37990,7 +38441,7 @@ var TerminalCameraRelay = class {
37990
38441
  res.writeHead(404).end();
37991
38442
  return;
37992
38443
  }
37993
- this.serve(target.nodeId, target.profileId, res);
38444
+ this.serve(target.instanceId, target.nodeId, target.profileId, res);
37994
38445
  });
37995
38446
  await new Promise((resolve, reject) => {
37996
38447
  server.once("error", reject);
@@ -38004,55 +38455,105 @@ var TerminalCameraRelay = class {
38004
38455
  this.server = server;
38005
38456
  this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
38006
38457
  }
38007
- streamUrl(nodeId, profileId) {
38458
+ streamUrl(instanceId, nodeId, profileId) {
38008
38459
  if (!this.baseUrl) throw new Error("terminal camera relay is not started");
38009
- return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38460
+ return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38010
38461
  }
38011
38462
  async listProfiles(nodeId) {
38012
38463
  return this.api.listProfiles(nodeId);
38013
38464
  }
38014
- state(nodeId, profileId) {
38015
- const key = relayKey(nodeId, profileId);
38465
+ state(instanceId, nodeId, profileId) {
38466
+ const key = relayKey(instanceId);
38016
38467
  const existing = this.states.get(key);
38017
38468
  if (existing) return existing;
38018
38469
  const created = {
38470
+ instanceId,
38019
38471
  nodeId,
38020
38472
  profileId,
38021
38473
  screen: createXtermScreen(120, 40),
38022
38474
  sessionId: null,
38023
38475
  cursor: 0,
38024
38476
  clients: 0,
38477
+ leases: 0,
38025
38478
  jpeg: null,
38026
38479
  renderedCursor: -1,
38027
38480
  framePromise: null,
38028
- idleTimer: null
38481
+ openPromise: null,
38482
+ idleTimer: null,
38483
+ closing: false,
38484
+ closePromise: null,
38485
+ closeRetryTimer: null,
38486
+ closeAttempts: 0,
38487
+ closed: false,
38488
+ responses: /* @__PURE__ */ new Set()
38029
38489
  };
38030
38490
  this.states.set(key, created);
38031
38491
  return created;
38032
38492
  }
38033
38493
  async ensureSession(state) {
38494
+ if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
38034
38495
  if (state.sessionId) return state.sessionId;
38035
- const opened = await this.api.openSession(state.nodeId, {
38496
+ if (state.openPromise) {
38497
+ const opened = await state.openPromise;
38498
+ if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
38499
+ return opened.sessionId;
38500
+ }
38501
+ const opening = this.api.openSession(state.nodeId, {
38036
38502
  profileId: state.profileId,
38037
38503
  cols: 120,
38038
38504
  rows: 40
38039
38505
  });
38040
- state.sessionId = opened.sessionId;
38041
- state.cursor = 0;
38042
- return opened.sessionId;
38506
+ state.openPromise = opening;
38507
+ try {
38508
+ const opened = await opening;
38509
+ state.openPromise = null;
38510
+ if (state.closed || state.closing) {
38511
+ state.sessionId = opened.sessionId;
38512
+ await this.closeState(state);
38513
+ throw new Error("terminal camera relay state closed while opening its session");
38514
+ }
38515
+ state.sessionId = opened.sessionId;
38516
+ state.cursor = 0;
38517
+ return opened.sessionId;
38518
+ } catch (error) {
38519
+ if (state.closing && !state.sessionId) this.finishClose(state);
38520
+ throw error;
38521
+ } finally {
38522
+ if (state.openPromise === opening) state.openPromise = null;
38523
+ }
38043
38524
  }
38044
- async nextFrame(state) {
38045
- if (state.framePromise) return state.framePromise;
38525
+ async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38526
+ if (state.framePromise) {
38527
+ await state.framePromise;
38528
+ return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38529
+ }
38046
38530
  const render = async () => {
38531
+ const openingSession = state.sessionId === null;
38047
38532
  const sessionId = await this.ensureSession(state);
38048
38533
  let batch;
38049
38534
  try {
38050
38535
  batch = await this.api.pullOutput(state.nodeId, {
38051
38536
  sessionId,
38052
- afterSeq: state.cursor
38537
+ afterSeq: state.cursor,
38538
+ ...openingSession && waitForInitialOutput ? {
38539
+ waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38540
+ waitForOutput: true
38541
+ } : {}
38053
38542
  });
38054
38543
  } catch (error) {
38055
- state.sessionId = null;
38544
+ if (state.sessionId === sessionId) {
38545
+ let closed = false;
38546
+ await this.api.closeSession(state.nodeId, sessionId).then(() => {
38547
+ closed = true;
38548
+ }).catch((closeError) => {
38549
+ this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
38550
+ nodeId: state.nodeId,
38551
+ sessionId,
38552
+ error: closeError instanceof Error ? closeError.message : String(closeError)
38553
+ } });
38554
+ });
38555
+ if (closed) state.sessionId = null;
38556
+ }
38056
38557
  state.cursor = 0;
38057
38558
  throw error;
38058
38559
  }
@@ -38069,7 +38570,7 @@ var TerminalCameraRelay = class {
38069
38570
  }
38070
38571
  state.cursor = exited ? 0 : batch.cursor;
38071
38572
  await state.screen.flush();
38072
- if (state.jpeg === null || state.renderedCursor !== state.cursor) {
38573
+ if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38073
38574
  state.jpeg = await renderTerminalJpeg(state.screen.lines());
38074
38575
  state.renderedCursor = state.cursor;
38075
38576
  }
@@ -38080,14 +38581,15 @@ var TerminalCameraRelay = class {
38080
38581
  });
38081
38582
  return state.framePromise;
38082
38583
  }
38083
- async serve(nodeId, profileId, res) {
38584
+ async serve(instanceId, nodeId, profileId, res) {
38084
38585
  this.responses.add(res);
38085
- const state = this.state(nodeId, profileId);
38586
+ const state = this.state(instanceId, nodeId, profileId);
38086
38587
  if (state.idleTimer) clearTimeout(state.idleTimer);
38087
38588
  state.idleTimer = null;
38088
38589
  state.clients += 1;
38590
+ state.responses.add(res);
38089
38591
  try {
38090
- const first = await this.nextFrame(state);
38592
+ const first = await this.nextFrame(state, false, true);
38091
38593
  res.writeHead(200, {
38092
38594
  "content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
38093
38595
  "cache-control": "no-store",
@@ -38115,11 +38617,13 @@ var TerminalCameraRelay = class {
38115
38617
  res.end("terminal camera unavailable");
38116
38618
  } finally {
38117
38619
  this.responses.delete(res);
38620
+ state.responses.delete(res);
38118
38621
  state.clients = Math.max(0, state.clients - 1);
38119
- if (state.clients === 0) this.scheduleIdleClose(state);
38622
+ if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
38120
38623
  }
38121
38624
  }
38122
38625
  scheduleIdleClose(state) {
38626
+ if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
38123
38627
  if (state.idleTimer) clearTimeout(state.idleTimer);
38124
38628
  state.idleTimer = setTimeout(() => {
38125
38629
  this.closeState(state);
@@ -38127,26 +38631,116 @@ var TerminalCameraRelay = class {
38127
38631
  state.idleTimer.unref?.();
38128
38632
  }
38129
38633
  async closeState(state) {
38130
- if (state.clients > 0) return;
38131
- if (state.sessionId) await this.api.closeSession(state.nodeId, state.sessionId).catch((error) => {
38634
+ if (state.closed) return;
38635
+ if (state.clients > 0 || state.leases > 0) return;
38636
+ if (state.closePromise) {
38637
+ await state.closePromise;
38638
+ return;
38639
+ }
38640
+ if (state.openPromise && !state.sessionId) {
38641
+ state.closing = true;
38642
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38643
+ state.idleTimer = null;
38644
+ return;
38645
+ }
38646
+ if (state.closing && !state.sessionId) return;
38647
+ state.closing = true;
38648
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38649
+ state.idleTimer = null;
38650
+ state.closePromise = this.closeWithRetries(state).finally(() => {
38651
+ state.closePromise = null;
38652
+ });
38653
+ await state.closePromise;
38654
+ }
38655
+ async closeWithRetries(state) {
38656
+ const sessionId = state.sessionId;
38657
+ if (!sessionId) {
38658
+ this.finishClose(state);
38659
+ return;
38660
+ }
38661
+ for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
38662
+ await this.api.closeSession(state.nodeId, sessionId);
38663
+ if (state.sessionId === sessionId) this.finishClose(state);
38664
+ return;
38665
+ } catch (error) {
38666
+ state.closeAttempts += 1;
38132
38667
  this.logger.warn("terminal camera session close failed", { meta: {
38133
38668
  nodeId: state.nodeId,
38134
- sessionId: state.sessionId,
38669
+ sessionId,
38670
+ attempt: state.closeAttempts,
38135
38671
  error: error instanceof Error ? error.message : String(error)
38136
38672
  } });
38137
- });
38673
+ if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
38674
+ }
38675
+ this.scheduleCloseRetry(state);
38676
+ }
38677
+ scheduleCloseRetry(state) {
38678
+ if (state.closeRetryTimer || !state.sessionId) return;
38679
+ state.closeRetryTimer = setTimeout(() => {
38680
+ state.closeRetryTimer = null;
38681
+ state.closing = false;
38682
+ this.closeState(state);
38683
+ }, this.closeRetryDelay(state.closeAttempts));
38684
+ state.closeRetryTimer.unref?.();
38685
+ }
38686
+ closeRetryDelay(attempt) {
38687
+ return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
38688
+ }
38689
+ finishClose(state) {
38690
+ if (state.closed) return;
38691
+ state.closed = true;
38692
+ if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
38693
+ state.closeRetryTimer = null;
38694
+ state.sessionId = null;
38695
+ state.closeAttempts = 0;
38696
+ state.closing = false;
38697
+ if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
38138
38698
  state.screen.dispose();
38139
- this.states.delete(relayKey(state.nodeId, state.profileId));
38699
+ }
38700
+ /**
38701
+ * Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
38702
+ * A snapshot-only caller owns a short lease and tears the state down as soon
38703
+ * as the image is rendered, so snapshots never leave a monitor PTY running.
38704
+ */
38705
+ async snapshot(instanceId, nodeId, profileId) {
38706
+ const state = this.state(instanceId, nodeId, profileId);
38707
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38708
+ state.idleTimer = null;
38709
+ state.leases += 1;
38710
+ try {
38711
+ return await this.nextFrame(state, true, true);
38712
+ } finally {
38713
+ state.leases = Math.max(0, state.leases - 1);
38714
+ if (state.clients === 0 && state.leases === 0) await this.closeState(state);
38715
+ }
38716
+ }
38717
+ /** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
38718
+ async closeInstance(instanceId) {
38719
+ const state = this.states.get(relayKey(instanceId));
38720
+ if (!state) return;
38721
+ for (const response of state.responses) response.destroy();
38722
+ state.responses.clear();
38723
+ state.clients = 0;
38724
+ state.leases = 0;
38725
+ await this.closeState(state);
38726
+ if (state.openPromise) {
38727
+ await state.openPromise.catch(() => {});
38728
+ await this.closeState(state);
38729
+ }
38140
38730
  }
38141
38731
  async dispose() {
38142
38732
  for (const response of this.responses) response.destroy();
38143
38733
  this.responses.clear();
38144
- for (const state of this.states.values()) {
38734
+ for (const state of [...this.states.values()]) {
38145
38735
  if (state.idleTimer) clearTimeout(state.idleTimer);
38146
38736
  state.clients = 0;
38737
+ state.leases = 0;
38147
38738
  await this.closeState(state);
38739
+ if (state.openPromise) {
38740
+ await state.openPromise.catch(() => {});
38741
+ await this.closeState(state);
38742
+ }
38148
38743
  }
38149
- this.states.clear();
38150
38744
  if (this.server) {
38151
38745
  const server = this.server;
38152
38746
  this.server = null;
@@ -38262,6 +38856,113 @@ function createTerminalDataPlaneHandler(manager) {
38262
38856
  };
38263
38857
  }
38264
38858
  //#endregion
38859
+ //#region src/terminal-instances.ts
38860
+ var TerminalInstanceSchema = object({
38861
+ id: string().uuid(),
38862
+ cameraStableId: string().min(1).max(256),
38863
+ nodeId: string().min(1).max(256),
38864
+ profileId: string().min(1).max(64),
38865
+ profileLabel: string().min(1).max(120),
38866
+ name: string().min(1).max(160),
38867
+ enabled: boolean()
38868
+ });
38869
+ /**
38870
+ * Config is operator-writable, so malformed or duplicate rows are ignored
38871
+ * rather than allowed to make declaration reconciliation destructive.
38872
+ */
38873
+ function readTerminalInstances(raw, onInvalid) {
38874
+ const ids = /* @__PURE__ */ new Set();
38875
+ const stableIds = /* @__PURE__ */ new Set();
38876
+ const instances = [];
38877
+ for (const value of raw) {
38878
+ const parsed = TerminalInstanceSchema.safeParse(value);
38879
+ if (!parsed.success) {
38880
+ onInvalid?.("Ignoring malformed Terminal instance configuration");
38881
+ continue;
38882
+ }
38883
+ const instance = parsed.data;
38884
+ if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
38885
+ onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
38886
+ continue;
38887
+ }
38888
+ ids.add(instance.id);
38889
+ stableIds.add(instance.cameraStableId);
38890
+ instances.push(instance);
38891
+ }
38892
+ return instances;
38893
+ }
38894
+ function newTerminalCameraStableId(instanceId) {
38895
+ return `terminal-camera-instance-${instanceId}`;
38896
+ }
38897
+ /**
38898
+ * Legacy automatic cameras are migration candidates only. A tombstone is
38899
+ * durable deletion intent, so a lingering failed device removal must never
38900
+ * make that camera adoptable again.
38901
+ */
38902
+ function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
38903
+ const legacy = [];
38904
+ for (const row of rows) {
38905
+ if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
38906
+ const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
38907
+ if (!nodeId) continue;
38908
+ const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
38909
+ const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
38910
+ legacy.push({
38911
+ stableId: row.stableId,
38912
+ nodeId,
38913
+ profileId,
38914
+ profileLabel,
38915
+ name: row.name,
38916
+ adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
38917
+ });
38918
+ }
38919
+ return legacy;
38920
+ }
38921
+ /** Serializes config read-modify-write operations and their reconciliation. */
38922
+ var TerminalInstanceMutationQueue = class {
38923
+ tail = Promise.resolve();
38924
+ async run(mutation) {
38925
+ const previous = this.tail;
38926
+ let release;
38927
+ this.tail = new Promise((resolve) => {
38928
+ release = resolve;
38929
+ });
38930
+ await previous;
38931
+ try {
38932
+ return await mutation();
38933
+ } finally {
38934
+ release?.();
38935
+ }
38936
+ }
38937
+ };
38938
+ /**
38939
+ * Coalesces periodic/config reconciliation requests onto the same serialized
38940
+ * lane as instance mutations. A pass never applies a declaration snapshot
38941
+ * concurrently with a create/delete/enable write.
38942
+ */
38943
+ var TerminalInstanceReconcileCoordinator = class {
38944
+ queue;
38945
+ dirty = false;
38946
+ running = null;
38947
+ constructor(queue) {
38948
+ this.queue = queue;
38949
+ }
38950
+ request(apply) {
38951
+ this.dirty = true;
38952
+ if (this.running) return this.running;
38953
+ const running = this.queue.run(async () => {
38954
+ while (this.dirty) {
38955
+ this.dirty = false;
38956
+ await apply();
38957
+ }
38958
+ });
38959
+ this.running = running.finally(() => {
38960
+ this.running = null;
38961
+ });
38962
+ return this.running;
38963
+ }
38964
+ };
38965
+ //#endregion
38265
38966
  //#region src/profiles.ts
38266
38967
  /**
38267
38968
  * The allowlist of programs an operator may open. The capability accepts a
@@ -38272,7 +38973,12 @@ function createTerminalDataPlaneHandler(manager) {
38272
38973
  var ProfileIdSchema = string().trim().min(1).max(64).regex(/^[a-z][a-z0-9-]*$/, "must be a lowercase slug");
38273
38974
  var ConfiguredTerminalProfileSchema = object({
38274
38975
  enabled: boolean().default(true),
38275
- profileId: ProfileIdSchema.refine((id) => id !== "monitor" && id !== "shell", { message: "monitor and shell are reserved profile IDs" }),
38976
+ profileId: ProfileIdSchema.refine((id) => ![
38977
+ "monitor",
38978
+ "top",
38979
+ "glances",
38980
+ "shell"
38981
+ ].includes(id), { message: "monitor, top, glances and shell are reserved profile IDs" }),
38276
38982
  label: string().trim().min(1).max(100),
38277
38983
  description: string().trim().max(500).optional().default(""),
38278
38984
  executable: string().trim().min(1).max(1024).refine((value) => !value.includes("\0"), { message: "must not contain NUL bytes" }),
@@ -38287,17 +38993,36 @@ var ConfiguredTerminalProfileSchema = object({
38287
38993
  * surface.
38288
38994
  */
38289
38995
  function buildProfiles(options) {
38290
- const profiles = [{
38996
+ const profiles = [];
38997
+ if (options.btmEnabled !== false) profiles.push({
38291
38998
  profileId: "monitor",
38292
- label: "System Monitor",
38999
+ label: "BTM",
38293
39000
  description: "bottom (btm) — CPU, memory, network and process monitor",
38294
39001
  file: options.btmPath.trim().length > 0 ? options.btmPath.trim() : "btm",
38295
- args: [],
38296
- ...options.btmPath.trim().length === 0 ? { fallbacks: [{
38297
- file: "top",
38298
- args: []
38299
- }] } : {}
38300
- }];
39002
+ args: options.btmArgs ?? []
39003
+ });
39004
+ if (options.topEnabled !== false) profiles.push({
39005
+ profileId: "top",
39006
+ label: "Top",
39007
+ description: "The operating system process and resource monitor",
39008
+ file: options.topPath?.trim() || "top",
39009
+ args: options.topArgs ?? []
39010
+ });
39011
+ if (options.glancesEnabled !== false) {
39012
+ const configuredPath = options.glancesPath?.trim();
39013
+ const pythonPath = options.glancesPythonPath?.trim();
39014
+ profiles.push({
39015
+ profileId: "glances",
39016
+ label: "Glances",
39017
+ description: "Cross-platform curses monitor installed in CamStack embedded Python",
39018
+ file: configuredPath || pythonPath || "glances",
39019
+ args: configuredPath || !pythonPath ? options.glancesArgs ?? [] : [
39020
+ "-m",
39021
+ "glances",
39022
+ ...options.glancesArgs ?? []
39023
+ ]
39024
+ });
39025
+ }
38301
39026
  if (options.allowShell) profiles.push({
38302
39027
  profileId: "shell",
38303
39028
  label: "Shell (interactive)",
@@ -38366,10 +39091,20 @@ var TerminalSessionManager = class {
38366
39091
  now;
38367
39092
  resolveBinary;
38368
39093
  maxSessions;
39094
+ instanceControl = null;
38369
39095
  constructor(opts) {
38370
39096
  this.opts = opts;
38371
39097
  this.profiles = buildProfiles({
38372
39098
  btmPath: opts.btmPath,
39099
+ btmEnabled: opts.btmEnabled,
39100
+ btmArgs: opts.btmArgs,
39101
+ topEnabled: opts.topEnabled,
39102
+ topPath: opts.topPath,
39103
+ topArgs: opts.topArgs,
39104
+ glancesEnabled: opts.glancesEnabled,
39105
+ glancesPath: opts.glancesPath,
39106
+ glancesArgs: opts.glancesArgs,
39107
+ glancesPythonPath: opts.glancesPythonPath,
38373
39108
  allowShell: opts.allowShell,
38374
39109
  shellPath: opts.shellPath,
38375
39110
  customProfiles: opts.customProfiles,
@@ -38395,6 +39130,27 @@ var TerminalSessionManager = class {
38395
39130
  ...p.description !== void 0 ? { description: p.description } : {}
38396
39131
  }));
38397
39132
  }
39133
+ setInstanceControl(control) {
39134
+ this.instanceControl = control;
39135
+ }
39136
+ async listInstances() {
39137
+ return this.instanceControl?.listInstances() ?? [];
39138
+ }
39139
+ async createInstance(input) {
39140
+ return this.requireInstanceControl().createInstance(input);
39141
+ }
39142
+ async deleteInstance(input) {
39143
+ await this.requireInstanceControl().deleteInstance(input);
39144
+ }
39145
+ async setInstanceEnabled(input) {
39146
+ return this.requireInstanceControl().setInstanceEnabled(input);
39147
+ }
39148
+ async listLegacyCameras() {
39149
+ return this.instanceControl?.listLegacyCameras() ?? [];
39150
+ }
39151
+ async adoptLegacyMonitor(input) {
39152
+ return this.requireInstanceControl().adoptLegacyMonitor(input);
39153
+ }
38398
39154
  async listSessions() {
38399
39155
  return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
38400
39156
  }
@@ -38442,10 +39198,12 @@ var TerminalSessionManager = class {
38442
39198
  outputWaiters: /* @__PURE__ */ new Set(),
38443
39199
  outputChars: 0,
38444
39200
  nextSeq: 1,
38445
- exited: false
39201
+ exited: false,
39202
+ disposed: false
38446
39203
  };
38447
39204
  this.sessions.set(sessionId, session);
38448
39205
  pty.onData((data) => {
39206
+ if (session.exited) return;
38449
39207
  session.screen.write(data);
38450
39208
  this.appendOutput(session, {
38451
39209
  kind: "data",
@@ -38459,27 +39217,7 @@ var TerminalSessionManager = class {
38459
39217
  } catch {}
38460
39218
  });
38461
39219
  pty.onExit((event) => {
38462
- session.exited = true;
38463
- session.lastExit = event;
38464
- this.appendOutput(session, {
38465
- kind: "exit",
38466
- exitCode: event.exitCode,
38467
- ...event.signal !== void 0 ? { signal: event.signal } : {}
38468
- });
38469
- for (const sink of session.sinks) try {
38470
- sink({
38471
- kind: "exit",
38472
- exitCode: event.exitCode,
38473
- signal: event.signal
38474
- });
38475
- } catch {}
38476
- session.sinks.clear();
38477
- const retire = setTimeout(() => {
38478
- session.screen.dispose();
38479
- this.sessions.delete(sessionId);
38480
- }, EXITED_RETENTION_MS);
38481
- retire.unref?.();
38482
- session.retireTimer = retire;
39220
+ this.finishSession(sessionId, session, event, true);
38483
39221
  });
38484
39222
  this.opts.logger.info("terminal: session opened", { meta: {
38485
39223
  sessionId,
@@ -38510,6 +39248,7 @@ var TerminalSessionManager = class {
38510
39248
  async close(input) {
38511
39249
  const session = this.sessions.get(input.sessionId);
38512
39250
  if (!session) return;
39251
+ this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
38513
39252
  try {
38514
39253
  session.pty.kill();
38515
39254
  } catch {}
@@ -38518,7 +39257,7 @@ var TerminalSessionManager = class {
38518
39257
  async pullOutput(input) {
38519
39258
  const session = this.sessions.get(input.sessionId);
38520
39259
  if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
38521
- if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
39260
+ if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
38522
39261
  const wake = () => {
38523
39262
  clearTimeout(timer);
38524
39263
  session.outputWaiters.delete(wake);
@@ -38529,6 +39268,11 @@ var TerminalSessionManager = class {
38529
39268
  session.outputWaiters.add(wake);
38530
39269
  });
38531
39270
  const cursor = session.nextSeq - 1;
39271
+ if (session.disposed) return {
39272
+ cursor,
39273
+ reset: false,
39274
+ events: session.output.filter((event) => event.seq > input.afterSeq)
39275
+ };
38532
39276
  const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
38533
39277
  if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
38534
39278
  await session.screen.flush();
@@ -38609,24 +39353,71 @@ var TerminalSessionManager = class {
38609
39353
  }
38610
39354
  /** Kill every live session — called on addon shutdown. */
38611
39355
  disposeAll() {
38612
- for (const session of this.sessions.values()) {
38613
- if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39356
+ for (const [sessionId, session] of this.sessions) {
39357
+ this.finishSession(sessionId, session, { exitCode: 0 }, false);
38614
39358
  try {
38615
39359
  session.pty.kill();
38616
39360
  } catch {}
38617
- session.screen.dispose();
38618
39361
  }
38619
- this.sessions.clear();
39362
+ }
39363
+ finishSession(sessionId, session, exit, retainForLateExit) {
39364
+ if (session.exited) return;
39365
+ session.exited = true;
39366
+ session.lastExit = exit;
39367
+ this.appendOutput(session, {
39368
+ kind: "exit",
39369
+ exitCode: exit.exitCode,
39370
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39371
+ });
39372
+ for (const sink of session.sinks) try {
39373
+ sink({
39374
+ kind: "exit",
39375
+ exitCode: exit.exitCode,
39376
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39377
+ });
39378
+ } catch {}
39379
+ session.sinks.clear();
39380
+ if (!retainForLateExit) {
39381
+ this.disposeSession(session);
39382
+ this.sessions.delete(sessionId);
39383
+ return;
39384
+ }
39385
+ const retire = setTimeout(() => {
39386
+ this.disposeSession(session);
39387
+ this.sessions.delete(sessionId);
39388
+ }, EXITED_RETENTION_MS);
39389
+ retire.unref?.();
39390
+ session.retireTimer = retire;
39391
+ }
39392
+ disposeSession(session) {
39393
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39394
+ if (session.disposed) return;
39395
+ session.disposed = true;
39396
+ session.screen.dispose();
39397
+ }
39398
+ requireInstanceControl() {
39399
+ if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
39400
+ return this.instanceControl;
38620
39401
  }
38621
39402
  };
38622
39403
  //#endregion
38623
39404
  //#region src/addon.ts
38624
39405
  var DEFAULTS = {
38625
39406
  btmPath: "",
39407
+ btmEnabled: true,
39408
+ btmArgs: [],
39409
+ topEnabled: true,
39410
+ topPath: "",
39411
+ topArgs: [],
39412
+ glancesEnabled: true,
39413
+ glancesPath: "",
39414
+ glancesArgs: [],
38626
39415
  allowShell: false,
38627
39416
  shellPath: "",
38628
39417
  maxSessions: 4,
38629
- customProfiles: []
39418
+ customProfiles: [],
39419
+ terminalInstances: [],
39420
+ terminalCameraTombstones: []
38630
39421
  };
38631
39422
  var DATA_PLANE_PREFIX = "io";
38632
39423
  var CAMERA_RECONCILE_MS = 6e4;
@@ -38636,35 +39427,56 @@ var TerminalAddon = class extends BaseAddon {
38636
39427
  dataPlane = null;
38637
39428
  cameraRelay = null;
38638
39429
  cameraReconcileTimer = null;
39430
+ cameraProfilesByNode = /* @__PURE__ */ new Map();
39431
+ /** Prevent repeat writes when a legacy device stays live after migration. */
39432
+ migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
39433
+ terminalCameraTombstones = /* @__PURE__ */ new Set();
39434
+ instanceMutationQueue = new TerminalInstanceMutationQueue();
39435
+ terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
39436
+ glancesPythonPath = "";
38639
39437
  constructor() {
38640
39438
  super({ ...DEFAULTS });
38641
39439
  }
38642
39440
  async onInitialize() {
38643
39441
  await warmNodePty();
39442
+ this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
38644
39443
  const manager = new TerminalSessionManager({
38645
39444
  spawn: createNodePtySpawner(),
38646
39445
  screenFactory: createXtermScreen,
38647
39446
  resolveBinary: resolveExecutable,
38648
39447
  logger: this.ctx.logger,
38649
39448
  btmPath: this.config.btmPath,
39449
+ btmEnabled: this.config.btmEnabled,
39450
+ btmArgs: this.config.btmArgs,
39451
+ topEnabled: this.config.topEnabled,
39452
+ topPath: this.config.topPath,
39453
+ topArgs: this.config.topArgs,
39454
+ glancesEnabled: this.config.glancesEnabled,
39455
+ glancesPath: this.config.glancesPath,
39456
+ glancesArgs: this.config.glancesArgs,
39457
+ glancesPythonPath: this.glancesPythonPath,
38650
39458
  allowShell: this.config.allowShell,
38651
39459
  shellPath: this.config.shellPath,
38652
39460
  maxSessions: this.config.maxSessions,
38653
39461
  customProfiles: this.config.customProfiles
38654
39462
  });
38655
39463
  this.manager = manager;
39464
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38656
39465
  if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
39466
+ const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
38657
39467
  const cameraRelay = new TerminalCameraRelay({
38658
- listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
38659
- openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
38660
- pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
39468
+ listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
39469
+ openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
39470
+ pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
38661
39471
  closeSession: async (nodeId, sessionId) => {
38662
- await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
39472
+ if (nodeId === localNodeId) await manager.close({ sessionId });
39473
+ else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
38663
39474
  }
38664
39475
  }, this.ctx.logger.child("camera"));
38665
39476
  await cameraRelay.start();
38666
39477
  this.cameraRelay = cameraRelay;
38667
39478
  installTerminalCameraRelay(cameraRelay);
39479
+ manager.setInstanceControl(this.terminalInstanceControl());
38668
39480
  await this.reconcileTerminalCameras().catch((error) => {
38669
39481
  this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
38670
39482
  });
@@ -38689,13 +39501,26 @@ var TerminalAddon = class extends BaseAddon {
38689
39501
  }];
38690
39502
  }
38691
39503
  async onConfigChanged() {
39504
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38692
39505
  this.manager?.reconfigureProfiles({
38693
39506
  btmPath: this.config.btmPath,
39507
+ btmEnabled: this.config.btmEnabled,
39508
+ btmArgs: this.config.btmArgs,
39509
+ topEnabled: this.config.topEnabled,
39510
+ topPath: this.config.topPath,
39511
+ topArgs: this.config.topArgs,
39512
+ glancesEnabled: this.config.glancesEnabled,
39513
+ glancesPath: this.config.glancesPath,
39514
+ glancesArgs: this.config.glancesArgs,
39515
+ glancesPythonPath: this.glancesPythonPath,
38694
39516
  allowShell: this.config.allowShell,
38695
39517
  shellPath: this.config.shellPath,
38696
39518
  maxSessions: this.config.maxSessions,
38697
39519
  customProfiles: this.config.customProfiles
38698
39520
  });
39521
+ if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
39522
+ this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
39523
+ });
38699
39524
  }
38700
39525
  async onShutdown() {
38701
39526
  if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
@@ -38711,17 +39536,46 @@ var TerminalAddon = class extends BaseAddon {
38711
39536
  this.manager = null;
38712
39537
  }
38713
39538
  async reconcileTerminalCameras() {
39539
+ return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
39540
+ }
39541
+ async applyTerminalCameraReconciliation() {
38714
39542
  if (!this.cameraRelay) return;
39543
+ let terminalIntegrationId;
38715
39544
  const topology = await this.ctx.api.nodes.topology.query();
38716
39545
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38717
39546
  const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
39547
+ const nodeIds = new Set(nodes.map((node) => node.id));
39548
+ for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
39549
+ const unavailableNodeIds = /* @__PURE__ */ new Set();
39550
+ await Promise.all(nodes.map(async (node) => {
39551
+ try {
39552
+ this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
39553
+ } catch (error) {
39554
+ unavailableNodeIds.add(node.id);
39555
+ this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
39556
+ nodeId: node.id,
39557
+ cachedProfiles: this.cameraProfilesByNode.has(node.id),
39558
+ error: error instanceof Error ? error.message : String(error)
39559
+ } });
39560
+ }
39561
+ }));
39562
+ const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
39563
+ const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
38718
39564
  const result = await new DeclaredDevices({
38719
39565
  logger: this.ctx.logger.child("camera-declaration"),
38720
39566
  addonId: this.ctx.id,
38721
39567
  devices: this.ctx.kernel.devices,
38722
39568
  localNodeId: this.ctx.kernel.localNodeId,
38723
- getIntegration: async (addonId) => this.ctx.api.integrations.getByAddonId.query({ addonId }),
38724
- createIntegration: async (input) => this.ctx.api.integrations.create.mutate(input),
39569
+ getIntegration: async (addonId) => {
39570
+ const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
39571
+ terminalIntegrationId = integration?.id ?? null;
39572
+ return integration;
39573
+ },
39574
+ createIntegration: async (input) => {
39575
+ const integration = await this.ctx.api.integrations.create.mutate(input);
39576
+ terminalIntegrationId = integration.id;
39577
+ return integration;
39578
+ },
38725
39579
  updateIntegration: async ({ id, info }) => {
38726
39580
  await this.ctx.api.integrations.update.mutate({
38727
39581
  id,
@@ -38729,25 +39583,157 @@ var TerminalAddon = class extends BaseAddon {
38729
39583
  skipRestart: true
38730
39584
  });
38731
39585
  },
38732
- listOwnDevices: async () => this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id })
39586
+ listOwnDevices: async () => {
39587
+ return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
39588
+ }
38733
39589
  }).reconcile({
38734
39590
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38735
39591
  placement: "hub",
38736
- devices: nodes.map((node) => ({
38737
- stableId: `terminal-camera-${node.id}`,
38738
- name: node.isHub ? "Terminal Hub" : `Terminal ${node.name}`,
39592
+ devices: cameraDeclarations.map((camera) => ({
39593
+ stableId: camera.stableId,
39594
+ name: camera.name,
38739
39595
  type: DeviceType.Camera,
38740
39596
  DeviceClass: TerminalCameraDevice,
38741
- config: { nodeId: node.id },
39597
+ config: camera.config,
38742
39598
  role: "terminal-camera"
38743
39599
  }))
38744
39600
  });
38745
- const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
39601
+ const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
38746
39602
  for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
39603
+ const declaration = declarationsByStableId.get(outcome.stableId);
39604
+ if (declaration) {
39605
+ const config = outcome.device.config;
39606
+ if (!(config.get("instanceId") === declaration.config.instanceId && config.get("nodeId") === declaration.config.nodeId && config.get("profileId") === declaration.config.profileId && config.get("profileLabel") === declaration.config.profileLabel) || !this.migratedTerminalCameraConfigIds.has(outcome.device.id) && needsTerminalCameraConfigMigration(outcome.device.ctx.persistedConfig ?? {}, declaration)) {
39607
+ await config.setAll(declaration.config);
39608
+ this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39609
+ }
39610
+ }
38747
39611
  const nodeId = outcome.device.config.get("nodeId");
38748
- outcome.device.setNodeOnline(onlineByNode.get(nodeId) ?? false);
39612
+ const profileId = outcome.device.config.get("profileId");
39613
+ const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39614
+ outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
38749
39615
  }
38750
39616
  }
39617
+ terminalInstanceControl() {
39618
+ return {
39619
+ listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
39620
+ createInstance: async (input) => this.createTerminalInstance(input),
39621
+ deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
39622
+ setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
39623
+ listLegacyCameras: async () => this.listLegacyTerminalCameras(),
39624
+ adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
39625
+ };
39626
+ }
39627
+ terminalInstances() {
39628
+ return readTerminalInstances(this.config.terminalInstances, (message) => {
39629
+ this.ctx.logger.warn(message);
39630
+ });
39631
+ }
39632
+ instanceInfo(instance) {
39633
+ return {
39634
+ instanceId: instance.id,
39635
+ cameraStableId: instance.cameraStableId,
39636
+ nodeId: instance.nodeId,
39637
+ profileId: instance.profileId,
39638
+ profileLabel: instance.profileLabel,
39639
+ name: instance.name,
39640
+ enabled: instance.enabled
39641
+ };
39642
+ }
39643
+ replaceTerminalCameraTombstones(stableIds) {
39644
+ this.terminalCameraTombstones.clear();
39645
+ for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
39646
+ }
39647
+ async createTerminalInstance(input) {
39648
+ const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
39649
+ await this.reconcileTerminalCameras();
39650
+ return instance;
39651
+ }
39652
+ async createTerminalInstanceUnlocked(input) {
39653
+ const relay = this.cameraRelay;
39654
+ if (!relay) throw new Error("Terminal instances are managed on the hub");
39655
+ const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
39656
+ if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
39657
+ const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
39658
+ if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
39659
+ const id = crypto.randomUUID();
39660
+ const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
39661
+ const instance = {
39662
+ id,
39663
+ cameraStableId: newTerminalCameraStableId(id),
39664
+ nodeId: input.targetNodeId,
39665
+ profileId: profile.profileId,
39666
+ profileLabel: profile.label,
39667
+ name,
39668
+ enabled: true
39669
+ };
39670
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39671
+ return this.instanceInfo(instance);
39672
+ }
39673
+ async deleteTerminalInstance(instanceId) {
39674
+ await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
39675
+ await this.reconcileTerminalCameras();
39676
+ }
39677
+ async deleteTerminalInstanceUnlocked(instanceId) {
39678
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39679
+ if (!instance) return;
39680
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39681
+ await this.cameraRelay?.closeInstance(instance.id);
39682
+ await this.updateGlobalSettings({
39683
+ terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
39684
+ terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
39685
+ });
39686
+ }
39687
+ async setTerminalInstanceEnabled(instanceId, enabled) {
39688
+ const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
39689
+ await this.reconcileTerminalCameras();
39690
+ return instance;
39691
+ }
39692
+ async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
39693
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39694
+ if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
39695
+ if (!enabled) {
39696
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39697
+ await this.cameraRelay?.closeInstance(instance.id);
39698
+ }
39699
+ const updated = {
39700
+ ...instance,
39701
+ enabled
39702
+ };
39703
+ const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
39704
+ const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
39705
+ await this.updateGlobalSettings({
39706
+ terminalInstances,
39707
+ terminalCameraTombstones
39708
+ });
39709
+ return this.instanceInfo(updated);
39710
+ }
39711
+ async listLegacyTerminalCameras() {
39712
+ const instances = this.terminalInstances();
39713
+ const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
39714
+ return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
39715
+ }
39716
+ async adoptLegacyMonitor(stableId, requestedName) {
39717
+ const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
39718
+ await this.reconcileTerminalCameras();
39719
+ return instance;
39720
+ }
39721
+ async adoptLegacyMonitorUnlocked(stableId, requestedName) {
39722
+ if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
39723
+ const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
39724
+ if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
39725
+ const instance = {
39726
+ id: crypto.randomUUID(),
39727
+ cameraStableId: legacy.stableId,
39728
+ nodeId: legacy.nodeId,
39729
+ profileId: "monitor",
39730
+ profileLabel: legacy.profileLabel,
39731
+ name: requestedName?.trim() || legacy.name,
39732
+ enabled: true
39733
+ };
39734
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39735
+ return this.instanceInfo(instance);
39736
+ }
38751
39737
  globalSettingsSchema() {
38752
39738
  return this.schema({ sections: [{
38753
39739
  id: "terminal",
@@ -38755,15 +39741,78 @@ var TerminalAddon = class extends BaseAddon {
38755
39741
  description: "Interactive terminal sessions in the Admin UI. Only pre-declared profiles can be opened; a free-form command is never accepted.",
38756
39742
  columns: 2,
38757
39743
  fields: [
39744
+ this.field({
39745
+ type: "boolean",
39746
+ key: "btmEnabled",
39747
+ label: "Enable BTM camera",
39748
+ default: true,
39749
+ perNode: true
39750
+ }),
38758
39751
  this.field({
38759
39752
  type: "text",
38760
39753
  key: "btmPath",
38761
- label: "Monitor binary",
39754
+ label: "BTM binary",
38762
39755
  description: "Path to the `btm` (bottom) executable. Leave empty to resolve from PATH.",
38763
39756
  placeholder: "btm",
38764
39757
  default: "",
38765
39758
  perNode: true
38766
39759
  }),
39760
+ this.field({
39761
+ type: "tags",
39762
+ key: "btmArgs",
39763
+ label: "BTM arguments",
39764
+ description: "Exact arguments passed to btm.",
39765
+ default: [],
39766
+ perNode: true
39767
+ }),
39768
+ this.field({
39769
+ type: "boolean",
39770
+ key: "topEnabled",
39771
+ label: "Enable Top camera",
39772
+ default: true,
39773
+ perNode: true
39774
+ }),
39775
+ this.field({
39776
+ type: "text",
39777
+ key: "topPath",
39778
+ label: "Top binary",
39779
+ placeholder: "top",
39780
+ default: "",
39781
+ perNode: true
39782
+ }),
39783
+ this.field({
39784
+ type: "tags",
39785
+ key: "topArgs",
39786
+ label: "Top arguments",
39787
+ description: "Exact arguments passed to top.",
39788
+ default: [],
39789
+ perNode: true
39790
+ }),
39791
+ this.field({
39792
+ type: "boolean",
39793
+ key: "glancesEnabled",
39794
+ label: "Enable Glances camera",
39795
+ description: "Glances is installed automatically into CamStack embedded Python.",
39796
+ default: true,
39797
+ perNode: true
39798
+ }),
39799
+ this.field({
39800
+ type: "text",
39801
+ key: "glancesPath",
39802
+ label: "Glances binary override",
39803
+ description: "Optional executable override. Empty uses the automatically managed Python package.",
39804
+ placeholder: "glances",
39805
+ default: "",
39806
+ perNode: true
39807
+ }),
39808
+ this.field({
39809
+ type: "tags",
39810
+ key: "glancesArgs",
39811
+ label: "Glances arguments",
39812
+ description: "Exact arguments passed to Glances.",
39813
+ default: [],
39814
+ perNode: true
39815
+ }),
38767
39816
  this.field({
38768
39817
  type: "number",
38769
39818
  key: "maxSessions",
@@ -38795,7 +39844,7 @@ var TerminalAddon = class extends BaseAddon {
38795
39844
  }, {
38796
39845
  id: "terminal-profiles",
38797
39846
  title: "Custom profiles",
38798
- description: "Define allowlisted programs available from the Terminal page on this node. Session requests only carry the profile ID; commands cannot be overridden by clients.",
39847
+ description: "Enabled profiles are available templates on this node. Create a Terminal instance on the Terminal page to declare a camera; session requests only carry the profile ID and commands cannot be overridden by clients.",
38799
39848
  columns: 1,
38800
39849
  fields: [this.field({
38801
39850
  type: "editable-array",