@camstack/addon-terminal 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +1134 -214
  2. package/dist/addon.mjs +1134 -214
  3. package/package.json +2 -2
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",
16278
16358
  auth: "admin"
16279
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16359
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
16360
+ kind: "mutation",
16361
+ auth: "admin"
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,133 @@ 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 was the only
18432
+ * demand signal. Both of those are satisfiable by the client's own image
18433
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
18434
+ * in a previous session comes off disk with no network, no demand, and no
18435
+ * capture. Measured on the live hub: reopening after two minutes idle
18436
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
18437
+ * fleet only recovered because a later poll happened to observe a different
18438
+ * identity.
18439
+ *
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 CAPTURES, where
18444
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
18445
+ * newer" but that the overview poll is app-wide (a capturing overview would
18446
+ * dial every camera on the install) while this is called by a rendered
18447
+ * surface naming the tiles it is actually painting, at the width it is
18448
+ * painting them.
18449
+ *
18450
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
18451
+ * server-side keep-warm loop was removed (operator directive — on-demand,
18452
+ * always), so a camera nobody is looking at costs nothing at all.
18453
+ *
18454
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
18455
+ * returned `capturedAt` is the frame the link will serve, not the frame the
18456
+ * cache held when the client asked, so a first paint is honest and current
18457
+ * instead of a generation behind. A device that does not settle inside the
18458
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
18459
+ * carries it forward.
18460
+ *
18461
+ * `force` is never set on behalf of a client here. A sleeping battery camera
18462
+ * is reported with `sleeping: true` and the last frame it produced, however
18463
+ * old; the wrapper's existing sleep gate owns that decision and this method
18464
+ * adds no second one.
18465
+ */
18466
+ getSnapshotLinks: systemMethod(object({
18467
+ /** The tiles a surface is actually rendering. One entry per (device,
18468
+ * width) the caller will paint — the width is snapped to the server's
18469
+ * ladder and becomes part of the link's SIGNED identity. */
18289
18470
  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
- })));
18471
+ deviceId: number(),
18472
+ /** Target width in px. Omit for the frame as captured — correct
18473
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18474
+ width: number().int().positive().optional()
18475
+ })).min(1).max(200) }), array(object({
18476
+ deviceId: number(),
18477
+ /** Root-relative signed path, or null when the link plane is not
18478
+ * served (no data-plane facility). Present even for a device that has
18479
+ * never captured — the request is what triggers the first one (D94). */
18480
+ url: string().nullable(),
18481
+ /** Epoch ms of the frame this link serves. Null = never captured.
18482
+ * THE honest age: the tRPC path carried none before this. */
18483
+ capturedAt: number().nullable(),
18484
+ /** Age of that frame at the moment the answer was built. */
18485
+ ageMs: number().nullable(),
18486
+ /** Epoch ms after which `url` stops verifying. */
18487
+ expiresAt: number().nullable(),
18488
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18489
+ width: number().nullable(),
18490
+ /** The device has never produced a frame. An empty state, not a
18491
+ * failure — and never a reason to withhold the link (D94). */
18492
+ neverCaptured: boolean(),
18493
+ /** A sleeping battery camera: the frame is deliberately stale and will
18494
+ * NOT refresh in the background. A surface should say so rather than
18495
+ * present it as current. */
18496
+ sleeping: boolean()
18497
+ })))
18498
+ },
18499
+ status: {
18500
+ schema: SnapshotStatusSchema,
18501
+ kind: "poll"
18502
+ }
18503
+ };
18317
18504
  /**
18318
18505
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
18319
18506
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -18480,6 +18667,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
18480
18667
  locationId: string(),
18481
18668
  targetBytes: number().int().positive()
18482
18669
  }), EvictResultSchema, { kind: "mutation" });
18670
+ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
18671
+ kind: "mutation",
18672
+ auth: "admin"
18673
+ }), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
18674
+ kind: "mutation",
18675
+ auth: "admin"
18676
+ });
18483
18677
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18484
18678
  providerId: string().min(1),
18485
18679
  displayName: string().min(1),
@@ -18583,6 +18777,28 @@ var TerminalProfileInfoSchema = object({
18583
18777
  label: string(),
18584
18778
  description: string().optional()
18585
18779
  });
18780
+ /**
18781
+ * A durable operator-created Terminal instance. Profiles are templates; only
18782
+ * an instance declares a camera.
18783
+ */
18784
+ var TerminalInstanceInfoSchema = object({
18785
+ instanceId: string(),
18786
+ cameraStableId: string(),
18787
+ nodeId: string(),
18788
+ profileId: string(),
18789
+ profileLabel: string(),
18790
+ name: string(),
18791
+ enabled: boolean()
18792
+ });
18793
+ var TerminalLegacyCameraSchema = object({
18794
+ stableId: string(),
18795
+ nodeId: string(),
18796
+ profileId: string(),
18797
+ profileLabel: string(),
18798
+ name: string(),
18799
+ /** Only legacy monitor cameras can retain their historic stable identity. */
18800
+ adoptable: boolean()
18801
+ });
18586
18802
  var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
18587
18803
  seq: number().int().positive(),
18588
18804
  kind: literal("data"),
@@ -18602,10 +18818,9 @@ var TerminalOutputBatchSchema = object({
18602
18818
  /**
18603
18819
  * terminal-session — singleton system capability for interactive TTY sessions.
18604
18820
  *
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.
18821
+ * Owns both live PTY lifecycle and durable Terminal instance management.
18822
+ * Profiles are allowlisted templates; an explicit instance is the only path
18823
+ * that declares a camera.
18609
18824
  */
18610
18825
  var terminalSessionCapability = {
18611
18826
  name: "terminal-session",
@@ -18614,6 +18829,37 @@ var terminalSessionCapability = {
18614
18829
  methods: {
18615
18830
  /** Pre-declared profiles the operator may open. */
18616
18831
  listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
18832
+ /** Explicit durable Terminal instances, managed centrally on the hub. */
18833
+ listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
18834
+ createInstance: method(object({
18835
+ targetNodeId: string().min(1),
18836
+ profileId: string().min(1),
18837
+ name: string().trim().min(1).max(160).optional()
18838
+ }), TerminalInstanceInfoSchema, {
18839
+ kind: "mutation",
18840
+ auth: "admin"
18841
+ }),
18842
+ deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
18843
+ kind: "mutation",
18844
+ auth: "admin"
18845
+ }),
18846
+ setInstanceEnabled: method(object({
18847
+ instanceId: string().min(1),
18848
+ enabled: boolean()
18849
+ }), TerminalInstanceInfoSchema, {
18850
+ kind: "mutation",
18851
+ auth: "admin"
18852
+ }),
18853
+ /** Existing automatic cameras are shown for explicit migration only. */
18854
+ listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
18855
+ /** Explicitly adopt one legacy monitor camera, retaining its stable id. */
18856
+ adoptLegacyMonitor: method(object({
18857
+ stableId: string().min(1),
18858
+ name: string().trim().min(1).max(160).optional()
18859
+ }), TerminalInstanceInfoSchema, {
18860
+ kind: "mutation",
18861
+ auth: "admin"
18862
+ }),
18617
18863
  /** Live sessions currently hosted by the provider. */
18618
18864
  listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
18619
18865
  /**
@@ -18645,7 +18891,13 @@ var terminalSessionCapability = {
18645
18891
  pullOutput: method(object({
18646
18892
  sessionId: string(),
18647
18893
  afterSeq: number().int().nonnegative(),
18648
- waitMs: number().int().min(0).max(2e3).default(0)
18894
+ waitMs: number().int().min(0).max(2e3).default(0),
18895
+ /**
18896
+ * Wait when a just-opened session has no output yet. Kept opt-in so a
18897
+ * browser's initial repaint remains immediate; the camera snapshot
18898
+ * relay uses it to avoid encoding a blank startup frame.
18899
+ */
18900
+ waitForOutput: boolean().optional()
18649
18901
  }), TerminalOutputBatchSchema, {
18650
18902
  kind: "mutation",
18651
18903
  auth: "admin",
@@ -24624,13 +24876,19 @@ method(object({
24624
24876
  }), {
24625
24877
  kind: "mutation",
24626
24878
  auth: "admin"
24627
- }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24879
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
24628
24880
  kind: "mutation",
24629
24881
  auth: "admin"
24630
- }), method(object({}), array(RelocateJobSchema).readonly(), {
24631
- kind: "query",
24882
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
24883
+ kind: "mutation",
24884
+ auth: "admin"
24885
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
24886
+ kind: "mutation",
24632
24887
  auth: "admin"
24633
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24888
+ }), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
24889
+ kind: "mutation",
24890
+ auth: "admin"
24891
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24634
24892
  kind: "mutation",
24635
24893
  auth: "admin"
24636
24894
  });
@@ -27160,9 +27418,10 @@ var DeclaredDevices = class {
27160
27418
  }
27161
27419
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27162
27420
  const index = await this.readIndex();
27421
+ const live = await this.readLiveByStableId();
27163
27422
  const outcomes = [];
27164
27423
  for (const declaration of spec.devices) {
27165
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27424
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27166
27425
  if (outcome !== null) outcomes.push(outcome);
27167
27426
  }
27168
27427
  return {
@@ -27208,6 +27467,26 @@ var DeclaredDevices = class {
27208
27467
  return new Map(rows.map((row) => [row.stableId, row]));
27209
27468
  }
27210
27469
  /**
27470
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27471
+ *
27472
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27473
+ * is persisted rows, this is live objects. A row without an object must be
27474
+ * adopted; an object must be left exactly as it is.
27475
+ *
27476
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27477
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27478
+ * genuinely needs bringing up.
27479
+ */
27480
+ async readLiveByStableId() {
27481
+ try {
27482
+ const devices = await this.ports.devices.getAll();
27483
+ return new Map(devices.map((device) => [device.stableId, device]));
27484
+ } catch (err) {
27485
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27486
+ return /* @__PURE__ */ new Map();
27487
+ }
27488
+ }
27489
+ /**
27211
27490
  * One declaration: adopt what exists, create what does not.
27212
27491
  *
27213
27492
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27216,8 +27495,15 @@ var DeclaredDevices = class {
27216
27495
  * the declared name over the operator's rename. D49: that branch needs a
27217
27496
  * second read to agree.
27218
27497
  */
27219
- async applyDeclaration(declaration, integrationId, index) {
27498
+ async applyDeclaration(declaration, integrationId, index, live) {
27220
27499
  try {
27500
+ const alreadyLive = live.get(declaration.stableId);
27501
+ if (alreadyLive !== void 0) return {
27502
+ stableId: declaration.stableId,
27503
+ deviceId: alreadyLive.id,
27504
+ device: alreadyLive,
27505
+ created: false
27506
+ };
27221
27507
  let existing = index.get(declaration.stableId);
27222
27508
  if (existing === void 0) {
27223
27509
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -30322,7 +30608,7 @@ Object.freeze({
30322
30608
  addonId: null,
30323
30609
  access: "create"
30324
30610
  },
30325
- "pipelineAnalytics.cancelMediaRelocate": {
30611
+ "pipelineAnalytics.cancelStorageMigrationMove": {
30326
30612
  capName: "pipeline-analytics",
30327
30613
  capScope: "device",
30328
30614
  addonId: null,
@@ -30394,12 +30680,6 @@ Object.freeze({
30394
30680
  addonId: null,
30395
30681
  access: "view"
30396
30682
  },
30397
- "pipelineAnalytics.getMediaRelocateStatus": {
30398
- capName: "pipeline-analytics",
30399
- capScope: "device",
30400
- addonId: null,
30401
- access: "view"
30402
- },
30403
30683
  "pipelineAnalytics.getMotionEvents": {
30404
30684
  capName: "pipeline-analytics",
30405
30685
  capScope: "device",
@@ -30436,6 +30716,12 @@ Object.freeze({
30436
30716
  addonId: null,
30437
30717
  access: "view"
30438
30718
  },
30719
+ "pipelineAnalytics.getStorageMigrationMoveStatus": {
30720
+ capName: "pipeline-analytics",
30721
+ capScope: "device",
30722
+ addonId: null,
30723
+ access: "view"
30724
+ },
30439
30725
  "pipelineAnalytics.getTrack": {
30440
30726
  capName: "pipeline-analytics",
30441
30727
  capScope: "device",
@@ -30514,6 +30800,12 @@ Object.freeze({
30514
30800
  addonId: null,
30515
30801
  access: "view"
30516
30802
  },
30803
+ "pipelineAnalytics.pauseForStorageMigration": {
30804
+ capName: "pipeline-analytics",
30805
+ capScope: "device",
30806
+ addonId: null,
30807
+ access: "create"
30808
+ },
30517
30809
  "pipelineAnalytics.proposeRetrainAnnotations": {
30518
30810
  capName: "pipeline-analytics",
30519
30811
  capScope: "device",
@@ -30544,7 +30836,7 @@ Object.freeze({
30544
30836
  addonId: null,
30545
30837
  access: "create"
30546
30838
  },
30547
- "pipelineAnalytics.relocateMedia": {
30839
+ "pipelineAnalytics.refreshStorageLocationsForMigration": {
30548
30840
  capName: "pipeline-analytics",
30549
30841
  capScope: "device",
30550
30842
  addonId: null,
@@ -30556,6 +30848,12 @@ Object.freeze({
30556
30848
  addonId: null,
30557
30849
  access: "create"
30558
30850
  },
30851
+ "pipelineAnalytics.resumeForStorageMigration": {
30852
+ capName: "pipeline-analytics",
30853
+ capScope: "device",
30854
+ addonId: null,
30855
+ access: "create"
30856
+ },
30559
30857
  "pipelineAnalytics.saveRetrainAnnotations": {
30560
30858
  capName: "pipeline-analytics",
30561
30859
  capScope: "device",
@@ -30580,6 +30878,12 @@ Object.freeze({
30580
30878
  addonId: null,
30581
30879
  access: "create"
30582
30880
  },
30881
+ "pipelineAnalytics.startStorageMigrationMove": {
30882
+ capName: "pipeline-analytics",
30883
+ capScope: "device",
30884
+ addonId: null,
30885
+ access: "create"
30886
+ },
30583
30887
  "pipelineAnalytics.wipeAllAnalytics": {
30584
30888
  capName: "pipeline-analytics",
30585
30889
  capScope: "device",
@@ -30946,6 +31250,12 @@ Object.freeze({
30946
31250
  addonId: null,
30947
31251
  access: "view"
30948
31252
  },
31253
+ "pipelineOrchestrator.pauseForStorageMigration": {
31254
+ capName: "pipeline-orchestrator",
31255
+ capScope: "system",
31256
+ addonId: null,
31257
+ access: "create"
31258
+ },
30949
31259
  "pipelineOrchestrator.rebalance": {
30950
31260
  capName: "pipeline-orchestrator",
30951
31261
  capScope: "system",
@@ -30970,6 +31280,12 @@ Object.freeze({
30970
31280
  addonId: null,
30971
31281
  access: "view"
30972
31282
  },
31283
+ "pipelineOrchestrator.resumeForStorageMigration": {
31284
+ capName: "pipeline-orchestrator",
31285
+ capScope: "system",
31286
+ addonId: null,
31287
+ access: "create"
31288
+ },
30973
31289
  "pipelineOrchestrator.saveTemplate": {
30974
31290
  capName: "pipeline-orchestrator",
30975
31291
  capScope: "system",
@@ -31366,7 +31682,7 @@ Object.freeze({
31366
31682
  addonId: null,
31367
31683
  access: "create"
31368
31684
  },
31369
- "recording.cancelRelocate": {
31685
+ "recording.cancelStorageMigrationMove": {
31370
31686
  capName: "recording",
31371
31687
  capScope: "system",
31372
31688
  addonId: null,
@@ -31402,7 +31718,7 @@ Object.freeze({
31402
31718
  addonId: null,
31403
31719
  access: "view"
31404
31720
  },
31405
- "recording.getRelocateStatus": {
31721
+ "recording.getStorageMigrationMoveStatus": {
31406
31722
  capName: "recording",
31407
31723
  capScope: "system",
31408
31724
  addonId: null,
@@ -31426,6 +31742,12 @@ Object.freeze({
31426
31742
  addonId: null,
31427
31743
  access: "view"
31428
31744
  },
31745
+ "recording.pauseForStorageMigration": {
31746
+ capName: "recording",
31747
+ capScope: "system",
31748
+ addonId: null,
31749
+ access: "create"
31750
+ },
31429
31751
  "recording.pruneFootage": {
31430
31752
  capName: "recording",
31431
31753
  capScope: "system",
@@ -31444,7 +31766,7 @@ Object.freeze({
31444
31766
  addonId: null,
31445
31767
  access: "view"
31446
31768
  },
31447
- "recording.relocateFootage": {
31769
+ "recording.refreshStorageLocationsForMigration": {
31448
31770
  capName: "recording",
31449
31771
  capScope: "system",
31450
31772
  addonId: null,
@@ -31468,12 +31790,24 @@ Object.freeze({
31468
31790
  addonId: null,
31469
31791
  access: "create"
31470
31792
  },
31793
+ "recording.resumeForStorageMigration": {
31794
+ capName: "recording",
31795
+ capScope: "system",
31796
+ addonId: null,
31797
+ access: "create"
31798
+ },
31471
31799
  "recording.setDeviceConfig": {
31472
31800
  capName: "recording",
31473
31801
  capScope: "system",
31474
31802
  addonId: null,
31475
31803
  access: "create"
31476
31804
  },
31805
+ "recording.startStorageMigrationMove": {
31806
+ capName: "recording",
31807
+ capScope: "system",
31808
+ addonId: null,
31809
+ access: "create"
31810
+ },
31477
31811
  "recordingExport.cancelExport": {
31478
31812
  capName: "recordingExport",
31479
31813
  capScope: "system",
@@ -31864,6 +32198,30 @@ Object.freeze({
31864
32198
  addonId: null,
31865
32199
  access: "view"
31866
32200
  },
32201
+ "storageMigration.cancel": {
32202
+ capName: "storage-migration",
32203
+ capScope: "system",
32204
+ addonId: null,
32205
+ access: "create"
32206
+ },
32207
+ "storageMigration.plan": {
32208
+ capName: "storage-migration",
32209
+ capScope: "system",
32210
+ addonId: null,
32211
+ access: "view"
32212
+ },
32213
+ "storageMigration.start": {
32214
+ capName: "storage-migration",
32215
+ capScope: "system",
32216
+ addonId: null,
32217
+ access: "create"
32218
+ },
32219
+ "storageMigration.status": {
32220
+ capName: "storage-migration",
32221
+ capScope: "system",
32222
+ addonId: null,
32223
+ access: "view"
32224
+ },
31867
32225
  "storageProvider.abortUpload": {
31868
32226
  capName: "storage-provider",
31869
32227
  capScope: "system",
@@ -32242,12 +32600,42 @@ Object.freeze({
32242
32600
  addonId: null,
32243
32601
  access: "create"
32244
32602
  },
32603
+ "terminalSession.adoptLegacyMonitor": {
32604
+ capName: "terminal-session",
32605
+ capScope: "system",
32606
+ addonId: null,
32607
+ access: "create"
32608
+ },
32245
32609
  "terminalSession.close": {
32246
32610
  capName: "terminal-session",
32247
32611
  capScope: "system",
32248
32612
  addonId: null,
32249
32613
  access: "create"
32250
32614
  },
32615
+ "terminalSession.createInstance": {
32616
+ capName: "terminal-session",
32617
+ capScope: "system",
32618
+ addonId: null,
32619
+ access: "create"
32620
+ },
32621
+ "terminalSession.deleteInstance": {
32622
+ capName: "terminal-session",
32623
+ capScope: "system",
32624
+ addonId: null,
32625
+ access: "delete"
32626
+ },
32627
+ "terminalSession.listInstances": {
32628
+ capName: "terminal-session",
32629
+ capScope: "system",
32630
+ addonId: null,
32631
+ access: "view"
32632
+ },
32633
+ "terminalSession.listLegacyCameras": {
32634
+ capName: "terminal-session",
32635
+ capScope: "system",
32636
+ addonId: null,
32637
+ access: "view"
32638
+ },
32251
32639
  "terminalSession.listProfiles": {
32252
32640
  capName: "terminal-session",
32253
32641
  capScope: "system",
@@ -32278,6 +32666,12 @@ Object.freeze({
32278
32666
  addonId: null,
32279
32667
  access: "create"
32280
32668
  },
32669
+ "terminalSession.setInstanceEnabled": {
32670
+ capName: "terminal-session",
32671
+ capScope: "system",
32672
+ addonId: null,
32673
+ access: "create"
32674
+ },
32281
32675
  "terminalSession.writeInput": {
32282
32676
  capName: "terminal-session",
32283
32677
  capScope: "system",
@@ -33080,26 +33474,56 @@ async function warmNodePty() {
33080
33474
  }
33081
33475
  //#endregion
33082
33476
  //#region src/terminal-camera-declarations.ts
33083
- function buildTerminalCameraDeclarations(nodes, profilesByNode) {
33084
- return nodes.flatMap((node) => {
33085
- const nodeLabel = node.isHub ? "Hub" : node.name;
33086
- return (profilesByNode.get(node.id) ?? []).map((profile) => ({
33087
- stableId: profile.profileId === "monitor" ? `terminal-camera-${node.id}` : `terminal-camera-${node.id}-${profile.profileId}`,
33088
- name: `Terminal ${profile.label} - ${nodeLabel}`,
33089
- config: {
33090
- nodeId: node.id,
33091
- profileId: profile.profileId,
33092
- profileLabel: profile.label
33093
- }
33094
- }));
33095
- });
33477
+ /**
33478
+ * Feed DeclaredDevices every live declaration plus one deterministic orphan
33479
+ * batch. The generic sweep intentionally refuses an over-limit set; selecting
33480
+ * a batch here drains large historical Terminal orphan sets across convergence
33481
+ * passes without weakening that global safety guard.
33482
+ */
33483
+ function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
33484
+ if (!integrationId) return [];
33485
+ const declared = new Set(declarations.map((camera) => camera.stableId));
33486
+ const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
33487
+ 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)];
33488
+ }
33489
+ /** Explicit persisted instances, never the node × profile template matrix. */
33490
+ function buildTerminalInstanceCameraDeclarations(instances) {
33491
+ return instances.filter((instance) => instance.enabled).map((instance) => ({
33492
+ stableId: instance.cameraStableId,
33493
+ name: instance.name,
33494
+ config: {
33495
+ instanceId: instance.id,
33496
+ nodeId: instance.nodeId,
33497
+ profileId: instance.profileId,
33498
+ profileLabel: instance.profileLabel
33499
+ }
33500
+ }));
33501
+ }
33502
+ /**
33503
+ * `DeviceConfig` materializes schema defaults in memory, so comparing
33504
+ * `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
33505
+ * inspect the raw persisted blob to make the profile migration durable.
33506
+ */
33507
+ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33508
+ return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33096
33509
  }
33097
33510
  function escapeXml(value) {
33098
33511
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33099
33512
  }
33100
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33513
+ /**
33514
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
33515
+ *
33516
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
33517
+ * runs of whitespace by default, and a terminal's entire column alignment IS
33518
+ * runs of whitespace — Glances pads every field with spaces. Without it the
33519
+ * frame drew each line at roughly half its true width, crammed into the
33520
+ * top-left of a mostly-black image, while the SAME session over `attach`
33521
+ * looked perfect — which is exactly how the operator reported it. Measured in
33522
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33523
+ * collapsed against 178 px preserved.
33524
+ */
33101
33525
  async function renderTerminalJpeg(lines) {
33102
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33526
+ const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33103
33527
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
33104
33528
  return (0, sharp.default)(Buffer.from(svg)).jpeg({
33105
33529
  quality: 82,
@@ -33109,22 +33533,35 @@ async function renderTerminalJpeg(lines) {
33109
33533
  //#endregion
33110
33534
  //#region src/terminal-camera-device.ts
33111
33535
  var terminalCameraSchema = object({
33536
+ instanceId: string().min(1).optional(),
33112
33537
  nodeId: string().min(1),
33113
- profileId: string().min(1),
33114
- profileLabel: string().min(1)
33538
+ profileId: string().min(1).default("monitor"),
33539
+ profileLabel: string().min(1).default("BTM")
33115
33540
  });
33116
33541
  var relay = null;
33117
33542
  function installTerminalCameraRelay(next) {
33118
33543
  relay = next;
33119
33544
  }
33120
33545
  var TerminalCameraDevice = class extends BaseDevice {
33121
- features = [];
33546
+ features = [DeviceFeature.NativeSnapshot];
33122
33547
  constructor(ctx) {
33123
33548
  super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
33124
33549
  this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
33125
33550
  if (deviceId !== this.id) return [];
33126
33551
  return this.catalog();
33127
33552
  } });
33553
+ this.ctx.registerNativeCap(snapshotCapability, {
33554
+ getSnapshot: async ({ deviceId }) => {
33555
+ if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
33556
+ const activeRelay = relay;
33557
+ if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33558
+ return {
33559
+ base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
33560
+ contentType: "image/jpeg"
33561
+ };
33562
+ },
33563
+ invalidateCache: async () => {}
33564
+ });
33128
33565
  this.markOnline(true);
33129
33566
  }
33130
33567
  async catalog() {
@@ -33132,10 +33569,11 @@ var TerminalCameraDevice = class extends BaseDevice {
33132
33569
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33133
33570
  const nodeId = this.config.get("nodeId");
33134
33571
  const profileId = this.config.get("profileId");
33572
+ const instanceId = this.relayInstanceId();
33135
33573
  return [{
33136
33574
  camStreamId: profileId,
33137
33575
  kind: "pull-http",
33138
- url: activeRelay.streamUrl(nodeId, profileId),
33576
+ url: activeRelay.streamUrl(instanceId, nodeId, profileId),
33139
33577
  codec: "h264",
33140
33578
  resolution: {
33141
33579
  width: 960,
@@ -33147,6 +33585,13 @@ var TerminalCameraDevice = class extends BaseDevice {
33147
33585
  }
33148
33586
  setNodeOnline(online) {
33149
33587
  this.markOnline(online);
33588
+ if (!online) relay?.closeInstance(this.relayInstanceId());
33589
+ }
33590
+ async removeDevice() {
33591
+ await relay?.closeInstance(this.relayInstanceId());
33592
+ }
33593
+ relayInstanceId() {
33594
+ return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
33150
33595
  }
33151
33596
  };
33152
33597
  //#endregion
@@ -37995,18 +38440,24 @@ function createXtermScreen(cols, rows) {
37995
38440
  //#region src/terminal-camera-relay.ts
37996
38441
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
37997
38442
  var SESSION_IDLE_MS = 3e4;
37998
- function relayKey(nodeId, profileId) {
37999
- return `${nodeId}\0${profileId}`;
38443
+ var SNAPSHOT_STARTUP_WAIT_MS = 1500;
38444
+ var CLOSE_RETRY_BASE_MS = 50;
38445
+ var CLOSE_RETRY_MAX_MS = 1e3;
38446
+ var CLOSE_ATTEMPTS_PER_PASS = 3;
38447
+ function relayKey(instanceId) {
38448
+ return instanceId;
38000
38449
  }
38001
38450
  function parseStreamPath(url) {
38002
38451
  const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
38003
- if (parts.length !== 3 || parts[0] !== "stream") return null;
38452
+ if (parts.length !== 4 || parts[0] !== "stream") return null;
38004
38453
  try {
38005
- const nodeId = decodeURIComponent(parts[1] ?? "");
38006
- const profilePart = parts[2] ?? "";
38454
+ const instanceId = decodeURIComponent(parts[1] ?? "");
38455
+ const nodeId = decodeURIComponent(parts[2] ?? "");
38456
+ const profilePart = parts[3] ?? "";
38007
38457
  if (!profilePart.endsWith(".mjpeg")) return null;
38008
38458
  const profileId = decodeURIComponent(profilePart.slice(0, -6));
38009
- return nodeId && profileId ? {
38459
+ return instanceId && nodeId && profileId ? {
38460
+ instanceId,
38010
38461
  nodeId,
38011
38462
  profileId
38012
38463
  } : null;
@@ -38033,7 +38484,7 @@ var TerminalCameraRelay = class {
38033
38484
  res.writeHead(404).end();
38034
38485
  return;
38035
38486
  }
38036
- this.serve(target.nodeId, target.profileId, res);
38487
+ this.serve(target.instanceId, target.nodeId, target.profileId, res);
38037
38488
  });
38038
38489
  await new Promise((resolve, reject) => {
38039
38490
  server.once("error", reject);
@@ -38047,55 +38498,105 @@ var TerminalCameraRelay = class {
38047
38498
  this.server = server;
38048
38499
  this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
38049
38500
  }
38050
- streamUrl(nodeId, profileId) {
38501
+ streamUrl(instanceId, nodeId, profileId) {
38051
38502
  if (!this.baseUrl) throw new Error("terminal camera relay is not started");
38052
- return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38503
+ return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38053
38504
  }
38054
38505
  async listProfiles(nodeId) {
38055
38506
  return this.api.listProfiles(nodeId);
38056
38507
  }
38057
- state(nodeId, profileId) {
38058
- const key = relayKey(nodeId, profileId);
38508
+ state(instanceId, nodeId, profileId) {
38509
+ const key = relayKey(instanceId);
38059
38510
  const existing = this.states.get(key);
38060
38511
  if (existing) return existing;
38061
38512
  const created = {
38513
+ instanceId,
38062
38514
  nodeId,
38063
38515
  profileId,
38064
38516
  screen: createXtermScreen(120, 40),
38065
38517
  sessionId: null,
38066
38518
  cursor: 0,
38067
38519
  clients: 0,
38520
+ leases: 0,
38068
38521
  jpeg: null,
38069
38522
  renderedCursor: -1,
38070
38523
  framePromise: null,
38071
- idleTimer: null
38524
+ openPromise: null,
38525
+ idleTimer: null,
38526
+ closing: false,
38527
+ closePromise: null,
38528
+ closeRetryTimer: null,
38529
+ closeAttempts: 0,
38530
+ closed: false,
38531
+ responses: /* @__PURE__ */ new Set()
38072
38532
  };
38073
38533
  this.states.set(key, created);
38074
38534
  return created;
38075
38535
  }
38076
38536
  async ensureSession(state) {
38537
+ if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
38077
38538
  if (state.sessionId) return state.sessionId;
38078
- const opened = await this.api.openSession(state.nodeId, {
38539
+ if (state.openPromise) {
38540
+ const opened = await state.openPromise;
38541
+ if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
38542
+ return opened.sessionId;
38543
+ }
38544
+ const opening = this.api.openSession(state.nodeId, {
38079
38545
  profileId: state.profileId,
38080
38546
  cols: 120,
38081
38547
  rows: 40
38082
38548
  });
38083
- state.sessionId = opened.sessionId;
38084
- state.cursor = 0;
38085
- return opened.sessionId;
38549
+ state.openPromise = opening;
38550
+ try {
38551
+ const opened = await opening;
38552
+ state.openPromise = null;
38553
+ if (state.closed || state.closing) {
38554
+ state.sessionId = opened.sessionId;
38555
+ await this.closeState(state);
38556
+ throw new Error("terminal camera relay state closed while opening its session");
38557
+ }
38558
+ state.sessionId = opened.sessionId;
38559
+ state.cursor = 0;
38560
+ return opened.sessionId;
38561
+ } catch (error) {
38562
+ if (state.closing && !state.sessionId) this.finishClose(state);
38563
+ throw error;
38564
+ } finally {
38565
+ if (state.openPromise === opening) state.openPromise = null;
38566
+ }
38086
38567
  }
38087
- async nextFrame(state) {
38088
- if (state.framePromise) return state.framePromise;
38568
+ async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38569
+ if (state.framePromise) {
38570
+ await state.framePromise;
38571
+ return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38572
+ }
38089
38573
  const render = async () => {
38574
+ const openingSession = state.sessionId === null;
38090
38575
  const sessionId = await this.ensureSession(state);
38091
38576
  let batch;
38092
38577
  try {
38093
38578
  batch = await this.api.pullOutput(state.nodeId, {
38094
38579
  sessionId,
38095
- afterSeq: state.cursor
38580
+ afterSeq: state.cursor,
38581
+ ...openingSession && waitForInitialOutput ? {
38582
+ waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38583
+ waitForOutput: true
38584
+ } : {}
38096
38585
  });
38097
38586
  } catch (error) {
38098
- state.sessionId = null;
38587
+ if (state.sessionId === sessionId) {
38588
+ let closed = false;
38589
+ await this.api.closeSession(state.nodeId, sessionId).then(() => {
38590
+ closed = true;
38591
+ }).catch((closeError) => {
38592
+ this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
38593
+ nodeId: state.nodeId,
38594
+ sessionId,
38595
+ error: closeError instanceof Error ? closeError.message : String(closeError)
38596
+ } });
38597
+ });
38598
+ if (closed) state.sessionId = null;
38599
+ }
38099
38600
  state.cursor = 0;
38100
38601
  throw error;
38101
38602
  }
@@ -38112,7 +38613,7 @@ var TerminalCameraRelay = class {
38112
38613
  }
38113
38614
  state.cursor = exited ? 0 : batch.cursor;
38114
38615
  await state.screen.flush();
38115
- if (state.jpeg === null || state.renderedCursor !== state.cursor) {
38616
+ if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38116
38617
  state.jpeg = await renderTerminalJpeg(state.screen.lines());
38117
38618
  state.renderedCursor = state.cursor;
38118
38619
  }
@@ -38123,14 +38624,15 @@ var TerminalCameraRelay = class {
38123
38624
  });
38124
38625
  return state.framePromise;
38125
38626
  }
38126
- async serve(nodeId, profileId, res) {
38627
+ async serve(instanceId, nodeId, profileId, res) {
38127
38628
  this.responses.add(res);
38128
- const state = this.state(nodeId, profileId);
38629
+ const state = this.state(instanceId, nodeId, profileId);
38129
38630
  if (state.idleTimer) clearTimeout(state.idleTimer);
38130
38631
  state.idleTimer = null;
38131
38632
  state.clients += 1;
38633
+ state.responses.add(res);
38132
38634
  try {
38133
- const first = await this.nextFrame(state);
38635
+ const first = await this.nextFrame(state, false, true);
38134
38636
  res.writeHead(200, {
38135
38637
  "content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
38136
38638
  "cache-control": "no-store",
@@ -38158,11 +38660,13 @@ var TerminalCameraRelay = class {
38158
38660
  res.end("terminal camera unavailable");
38159
38661
  } finally {
38160
38662
  this.responses.delete(res);
38663
+ state.responses.delete(res);
38161
38664
  state.clients = Math.max(0, state.clients - 1);
38162
- if (state.clients === 0) this.scheduleIdleClose(state);
38665
+ if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
38163
38666
  }
38164
38667
  }
38165
38668
  scheduleIdleClose(state) {
38669
+ if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
38166
38670
  if (state.idleTimer) clearTimeout(state.idleTimer);
38167
38671
  state.idleTimer = setTimeout(() => {
38168
38672
  this.closeState(state);
@@ -38170,26 +38674,116 @@ var TerminalCameraRelay = class {
38170
38674
  state.idleTimer.unref?.();
38171
38675
  }
38172
38676
  async closeState(state) {
38173
- if (state.clients > 0) return;
38174
- if (state.sessionId) await this.api.closeSession(state.nodeId, state.sessionId).catch((error) => {
38677
+ if (state.closed) return;
38678
+ if (state.clients > 0 || state.leases > 0) return;
38679
+ if (state.closePromise) {
38680
+ await state.closePromise;
38681
+ return;
38682
+ }
38683
+ if (state.openPromise && !state.sessionId) {
38684
+ state.closing = true;
38685
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38686
+ state.idleTimer = null;
38687
+ return;
38688
+ }
38689
+ if (state.closing && !state.sessionId) return;
38690
+ state.closing = true;
38691
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38692
+ state.idleTimer = null;
38693
+ state.closePromise = this.closeWithRetries(state).finally(() => {
38694
+ state.closePromise = null;
38695
+ });
38696
+ await state.closePromise;
38697
+ }
38698
+ async closeWithRetries(state) {
38699
+ const sessionId = state.sessionId;
38700
+ if (!sessionId) {
38701
+ this.finishClose(state);
38702
+ return;
38703
+ }
38704
+ for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
38705
+ await this.api.closeSession(state.nodeId, sessionId);
38706
+ if (state.sessionId === sessionId) this.finishClose(state);
38707
+ return;
38708
+ } catch (error) {
38709
+ state.closeAttempts += 1;
38175
38710
  this.logger.warn("terminal camera session close failed", { meta: {
38176
38711
  nodeId: state.nodeId,
38177
- sessionId: state.sessionId,
38712
+ sessionId,
38713
+ attempt: state.closeAttempts,
38178
38714
  error: error instanceof Error ? error.message : String(error)
38179
38715
  } });
38180
- });
38716
+ if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
38717
+ }
38718
+ this.scheduleCloseRetry(state);
38719
+ }
38720
+ scheduleCloseRetry(state) {
38721
+ if (state.closeRetryTimer || !state.sessionId) return;
38722
+ state.closeRetryTimer = setTimeout(() => {
38723
+ state.closeRetryTimer = null;
38724
+ state.closing = false;
38725
+ this.closeState(state);
38726
+ }, this.closeRetryDelay(state.closeAttempts));
38727
+ state.closeRetryTimer.unref?.();
38728
+ }
38729
+ closeRetryDelay(attempt) {
38730
+ return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
38731
+ }
38732
+ finishClose(state) {
38733
+ if (state.closed) return;
38734
+ state.closed = true;
38735
+ if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
38736
+ state.closeRetryTimer = null;
38737
+ state.sessionId = null;
38738
+ state.closeAttempts = 0;
38739
+ state.closing = false;
38740
+ if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
38181
38741
  state.screen.dispose();
38182
- this.states.delete(relayKey(state.nodeId, state.profileId));
38742
+ }
38743
+ /**
38744
+ * Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
38745
+ * A snapshot-only caller owns a short lease and tears the state down as soon
38746
+ * as the image is rendered, so snapshots never leave a monitor PTY running.
38747
+ */
38748
+ async snapshot(instanceId, nodeId, profileId) {
38749
+ const state = this.state(instanceId, nodeId, profileId);
38750
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38751
+ state.idleTimer = null;
38752
+ state.leases += 1;
38753
+ try {
38754
+ return await this.nextFrame(state, true, true);
38755
+ } finally {
38756
+ state.leases = Math.max(0, state.leases - 1);
38757
+ if (state.clients === 0 && state.leases === 0) await this.closeState(state);
38758
+ }
38759
+ }
38760
+ /** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
38761
+ async closeInstance(instanceId) {
38762
+ const state = this.states.get(relayKey(instanceId));
38763
+ if (!state) return;
38764
+ for (const response of state.responses) response.destroy();
38765
+ state.responses.clear();
38766
+ state.clients = 0;
38767
+ state.leases = 0;
38768
+ await this.closeState(state);
38769
+ if (state.openPromise) {
38770
+ await state.openPromise.catch(() => {});
38771
+ await this.closeState(state);
38772
+ }
38183
38773
  }
38184
38774
  async dispose() {
38185
38775
  for (const response of this.responses) response.destroy();
38186
38776
  this.responses.clear();
38187
- for (const state of this.states.values()) {
38777
+ for (const state of [...this.states.values()]) {
38188
38778
  if (state.idleTimer) clearTimeout(state.idleTimer);
38189
38779
  state.clients = 0;
38780
+ state.leases = 0;
38190
38781
  await this.closeState(state);
38782
+ if (state.openPromise) {
38783
+ await state.openPromise.catch(() => {});
38784
+ await this.closeState(state);
38785
+ }
38191
38786
  }
38192
- this.states.clear();
38193
38787
  if (this.server) {
38194
38788
  const server = this.server;
38195
38789
  this.server = null;
@@ -38305,6 +38899,113 @@ function createTerminalDataPlaneHandler(manager) {
38305
38899
  };
38306
38900
  }
38307
38901
  //#endregion
38902
+ //#region src/terminal-instances.ts
38903
+ var TerminalInstanceSchema = object({
38904
+ id: string().uuid(),
38905
+ cameraStableId: string().min(1).max(256),
38906
+ nodeId: string().min(1).max(256),
38907
+ profileId: string().min(1).max(64),
38908
+ profileLabel: string().min(1).max(120),
38909
+ name: string().min(1).max(160),
38910
+ enabled: boolean()
38911
+ });
38912
+ /**
38913
+ * Config is operator-writable, so malformed or duplicate rows are ignored
38914
+ * rather than allowed to make declaration reconciliation destructive.
38915
+ */
38916
+ function readTerminalInstances(raw, onInvalid) {
38917
+ const ids = /* @__PURE__ */ new Set();
38918
+ const stableIds = /* @__PURE__ */ new Set();
38919
+ const instances = [];
38920
+ for (const value of raw) {
38921
+ const parsed = TerminalInstanceSchema.safeParse(value);
38922
+ if (!parsed.success) {
38923
+ onInvalid?.("Ignoring malformed Terminal instance configuration");
38924
+ continue;
38925
+ }
38926
+ const instance = parsed.data;
38927
+ if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
38928
+ onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
38929
+ continue;
38930
+ }
38931
+ ids.add(instance.id);
38932
+ stableIds.add(instance.cameraStableId);
38933
+ instances.push(instance);
38934
+ }
38935
+ return instances;
38936
+ }
38937
+ function newTerminalCameraStableId(instanceId) {
38938
+ return `terminal-camera-instance-${instanceId}`;
38939
+ }
38940
+ /**
38941
+ * Legacy automatic cameras are migration candidates only. A tombstone is
38942
+ * durable deletion intent, so a lingering failed device removal must never
38943
+ * make that camera adoptable again.
38944
+ */
38945
+ function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
38946
+ const legacy = [];
38947
+ for (const row of rows) {
38948
+ if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
38949
+ const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
38950
+ if (!nodeId) continue;
38951
+ const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
38952
+ const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
38953
+ legacy.push({
38954
+ stableId: row.stableId,
38955
+ nodeId,
38956
+ profileId,
38957
+ profileLabel,
38958
+ name: row.name,
38959
+ adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
38960
+ });
38961
+ }
38962
+ return legacy;
38963
+ }
38964
+ /** Serializes config read-modify-write operations and their reconciliation. */
38965
+ var TerminalInstanceMutationQueue = class {
38966
+ tail = Promise.resolve();
38967
+ async run(mutation) {
38968
+ const previous = this.tail;
38969
+ let release;
38970
+ this.tail = new Promise((resolve) => {
38971
+ release = resolve;
38972
+ });
38973
+ await previous;
38974
+ try {
38975
+ return await mutation();
38976
+ } finally {
38977
+ release?.();
38978
+ }
38979
+ }
38980
+ };
38981
+ /**
38982
+ * Coalesces periodic/config reconciliation requests onto the same serialized
38983
+ * lane as instance mutations. A pass never applies a declaration snapshot
38984
+ * concurrently with a create/delete/enable write.
38985
+ */
38986
+ var TerminalInstanceReconcileCoordinator = class {
38987
+ queue;
38988
+ dirty = false;
38989
+ running = null;
38990
+ constructor(queue) {
38991
+ this.queue = queue;
38992
+ }
38993
+ request(apply) {
38994
+ this.dirty = true;
38995
+ if (this.running) return this.running;
38996
+ const running = this.queue.run(async () => {
38997
+ while (this.dirty) {
38998
+ this.dirty = false;
38999
+ await apply();
39000
+ }
39001
+ });
39002
+ this.running = running.finally(() => {
39003
+ this.running = null;
39004
+ });
39005
+ return this.running;
39006
+ }
39007
+ };
39008
+ //#endregion
38308
39009
  //#region src/profiles.ts
38309
39010
  /**
38310
39011
  * The allowlist of programs an operator may open. The capability accepts a
@@ -38433,6 +39134,7 @@ var TerminalSessionManager = class {
38433
39134
  now;
38434
39135
  resolveBinary;
38435
39136
  maxSessions;
39137
+ instanceControl = null;
38436
39138
  constructor(opts) {
38437
39139
  this.opts = opts;
38438
39140
  this.profiles = buildProfiles({
@@ -38471,6 +39173,27 @@ var TerminalSessionManager = class {
38471
39173
  ...p.description !== void 0 ? { description: p.description } : {}
38472
39174
  }));
38473
39175
  }
39176
+ setInstanceControl(control) {
39177
+ this.instanceControl = control;
39178
+ }
39179
+ async listInstances() {
39180
+ return this.instanceControl?.listInstances() ?? [];
39181
+ }
39182
+ async createInstance(input) {
39183
+ return this.requireInstanceControl().createInstance(input);
39184
+ }
39185
+ async deleteInstance(input) {
39186
+ await this.requireInstanceControl().deleteInstance(input);
39187
+ }
39188
+ async setInstanceEnabled(input) {
39189
+ return this.requireInstanceControl().setInstanceEnabled(input);
39190
+ }
39191
+ async listLegacyCameras() {
39192
+ return this.instanceControl?.listLegacyCameras() ?? [];
39193
+ }
39194
+ async adoptLegacyMonitor(input) {
39195
+ return this.requireInstanceControl().adoptLegacyMonitor(input);
39196
+ }
38474
39197
  async listSessions() {
38475
39198
  return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
38476
39199
  }
@@ -38518,10 +39241,12 @@ var TerminalSessionManager = class {
38518
39241
  outputWaiters: /* @__PURE__ */ new Set(),
38519
39242
  outputChars: 0,
38520
39243
  nextSeq: 1,
38521
- exited: false
39244
+ exited: false,
39245
+ disposed: false
38522
39246
  };
38523
39247
  this.sessions.set(sessionId, session);
38524
39248
  pty.onData((data) => {
39249
+ if (session.exited) return;
38525
39250
  session.screen.write(data);
38526
39251
  this.appendOutput(session, {
38527
39252
  kind: "data",
@@ -38535,27 +39260,7 @@ var TerminalSessionManager = class {
38535
39260
  } catch {}
38536
39261
  });
38537
39262
  pty.onExit((event) => {
38538
- session.exited = true;
38539
- session.lastExit = event;
38540
- this.appendOutput(session, {
38541
- kind: "exit",
38542
- exitCode: event.exitCode,
38543
- ...event.signal !== void 0 ? { signal: event.signal } : {}
38544
- });
38545
- for (const sink of session.sinks) try {
38546
- sink({
38547
- kind: "exit",
38548
- exitCode: event.exitCode,
38549
- signal: event.signal
38550
- });
38551
- } catch {}
38552
- session.sinks.clear();
38553
- const retire = setTimeout(() => {
38554
- session.screen.dispose();
38555
- this.sessions.delete(sessionId);
38556
- }, EXITED_RETENTION_MS);
38557
- retire.unref?.();
38558
- session.retireTimer = retire;
39263
+ this.finishSession(sessionId, session, event, true);
38559
39264
  });
38560
39265
  this.opts.logger.info("terminal: session opened", { meta: {
38561
39266
  sessionId,
@@ -38586,6 +39291,7 @@ var TerminalSessionManager = class {
38586
39291
  async close(input) {
38587
39292
  const session = this.sessions.get(input.sessionId);
38588
39293
  if (!session) return;
39294
+ this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
38589
39295
  try {
38590
39296
  session.pty.kill();
38591
39297
  } catch {}
@@ -38594,7 +39300,7 @@ var TerminalSessionManager = class {
38594
39300
  async pullOutput(input) {
38595
39301
  const session = this.sessions.get(input.sessionId);
38596
39302
  if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
38597
- if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
39303
+ if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
38598
39304
  const wake = () => {
38599
39305
  clearTimeout(timer);
38600
39306
  session.outputWaiters.delete(wake);
@@ -38605,6 +39311,11 @@ var TerminalSessionManager = class {
38605
39311
  session.outputWaiters.add(wake);
38606
39312
  });
38607
39313
  const cursor = session.nextSeq - 1;
39314
+ if (session.disposed) return {
39315
+ cursor,
39316
+ reset: false,
39317
+ events: session.output.filter((event) => event.seq > input.afterSeq)
39318
+ };
38608
39319
  const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
38609
39320
  if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
38610
39321
  await session.screen.flush();
@@ -38685,14 +39396,51 @@ var TerminalSessionManager = class {
38685
39396
  }
38686
39397
  /** Kill every live session — called on addon shutdown. */
38687
39398
  disposeAll() {
38688
- for (const session of this.sessions.values()) {
38689
- if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39399
+ for (const [sessionId, session] of this.sessions) {
39400
+ this.finishSession(sessionId, session, { exitCode: 0 }, false);
38690
39401
  try {
38691
39402
  session.pty.kill();
38692
39403
  } catch {}
38693
- session.screen.dispose();
38694
39404
  }
38695
- this.sessions.clear();
39405
+ }
39406
+ finishSession(sessionId, session, exit, retainForLateExit) {
39407
+ if (session.exited) return;
39408
+ session.exited = true;
39409
+ session.lastExit = exit;
39410
+ this.appendOutput(session, {
39411
+ kind: "exit",
39412
+ exitCode: exit.exitCode,
39413
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39414
+ });
39415
+ for (const sink of session.sinks) try {
39416
+ sink({
39417
+ kind: "exit",
39418
+ exitCode: exit.exitCode,
39419
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39420
+ });
39421
+ } catch {}
39422
+ session.sinks.clear();
39423
+ if (!retainForLateExit) {
39424
+ this.disposeSession(session);
39425
+ this.sessions.delete(sessionId);
39426
+ return;
39427
+ }
39428
+ const retire = setTimeout(() => {
39429
+ this.disposeSession(session);
39430
+ this.sessions.delete(sessionId);
39431
+ }, EXITED_RETENTION_MS);
39432
+ retire.unref?.();
39433
+ session.retireTimer = retire;
39434
+ }
39435
+ disposeSession(session) {
39436
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39437
+ if (session.disposed) return;
39438
+ session.disposed = true;
39439
+ session.screen.dispose();
39440
+ }
39441
+ requireInstanceControl() {
39442
+ if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
39443
+ return this.instanceControl;
38696
39444
  }
38697
39445
  };
38698
39446
  //#endregion
@@ -38710,7 +39458,9 @@ var DEFAULTS = {
38710
39458
  allowShell: false,
38711
39459
  shellPath: "",
38712
39460
  maxSessions: 4,
38713
- customProfiles: []
39461
+ customProfiles: [],
39462
+ terminalInstances: [],
39463
+ terminalCameraTombstones: []
38714
39464
  };
38715
39465
  var DATA_PLANE_PREFIX = "io";
38716
39466
  var CAMERA_RECONCILE_MS = 6e4;
@@ -38721,6 +39471,11 @@ var TerminalAddon = class extends BaseAddon {
38721
39471
  cameraRelay = null;
38722
39472
  cameraReconcileTimer = null;
38723
39473
  cameraProfilesByNode = /* @__PURE__ */ new Map();
39474
+ /** Prevent repeat writes when a legacy device stays live after migration. */
39475
+ migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
39476
+ terminalCameraTombstones = /* @__PURE__ */ new Set();
39477
+ instanceMutationQueue = new TerminalInstanceMutationQueue();
39478
+ terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
38724
39479
  glancesPythonPath = "";
38725
39480
  constructor() {
38726
39481
  super({ ...DEFAULTS });
@@ -38749,18 +39504,22 @@ var TerminalAddon = class extends BaseAddon {
38749
39504
  customProfiles: this.config.customProfiles
38750
39505
  });
38751
39506
  this.manager = manager;
39507
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38752
39508
  if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
39509
+ const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
38753
39510
  const cameraRelay = new TerminalCameraRelay({
38754
- listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
38755
- openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
38756
- pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
39511
+ listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
39512
+ openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
39513
+ pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
38757
39514
  closeSession: async (nodeId, sessionId) => {
38758
- await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
39515
+ if (nodeId === localNodeId) await manager.close({ sessionId });
39516
+ else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
38759
39517
  }
38760
39518
  }, this.ctx.logger.child("camera"));
38761
39519
  await cameraRelay.start();
38762
39520
  this.cameraRelay = cameraRelay;
38763
39521
  installTerminalCameraRelay(cameraRelay);
39522
+ manager.setInstanceControl(this.terminalInstanceControl());
38764
39523
  await this.reconcileTerminalCameras().catch((error) => {
38765
39524
  this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
38766
39525
  });
@@ -38785,6 +39544,7 @@ var TerminalAddon = class extends BaseAddon {
38785
39544
  }];
38786
39545
  }
38787
39546
  async onConfigChanged() {
39547
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38788
39548
  this.manager?.reconfigureProfiles({
38789
39549
  btmPath: this.config.btmPath,
38790
39550
  btmEnabled: this.config.btmEnabled,
@@ -38801,6 +39561,9 @@ var TerminalAddon = class extends BaseAddon {
38801
39561
  maxSessions: this.config.maxSessions,
38802
39562
  customProfiles: this.config.customProfiles
38803
39563
  });
39564
+ if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
39565
+ this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
39566
+ });
38804
39567
  }
38805
39568
  async onShutdown() {
38806
39569
  if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
@@ -38816,55 +39579,46 @@ var TerminalAddon = class extends BaseAddon {
38816
39579
  this.manager = null;
38817
39580
  }
38818
39581
  async reconcileTerminalCameras() {
39582
+ return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
39583
+ }
39584
+ async applyTerminalCameraReconciliation() {
38819
39585
  if (!this.cameraRelay) return;
39586
+ let terminalIntegrationId;
38820
39587
  const topology = await this.ctx.api.nodes.topology.query();
38821
39588
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38822
39589
  const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
39590
+ const nodeIds = new Set(nodes.map((node) => node.id));
39591
+ for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
38823
39592
  const unavailableNodeIds = /* @__PURE__ */ new Set();
38824
39593
  await Promise.all(nodes.map(async (node) => {
38825
39594
  try {
38826
39595
  this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
38827
39596
  } catch (error) {
38828
- if (!this.cameraProfilesByNode.has(node.id)) {
38829
- unavailableNodeIds.add(node.id);
38830
- this.ctx.logger.warn("terminal profiles unavailable — preserving existing cameras for this node", { meta: {
38831
- nodeId: node.id,
38832
- error: error instanceof Error ? error.message : String(error)
38833
- } });
38834
- }
39597
+ unavailableNodeIds.add(node.id);
39598
+ this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
39599
+ nodeId: node.id,
39600
+ cachedProfiles: this.cameraProfilesByNode.has(node.id),
39601
+ error: error instanceof Error ? error.message : String(error)
39602
+ } });
38835
39603
  }
38836
39604
  }));
38837
- const cameraDeclarations = [...buildTerminalCameraDeclarations(nodes, this.cameraProfilesByNode)];
38838
- if (unavailableNodeIds.size > 0) {
38839
- const existing = await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id });
38840
- const unavailableNodes = nodes.filter((node) => unavailableNodeIds.has(node.id)).sort((left, right) => right.id.length - left.id.length);
38841
- for (const row of existing) {
38842
- const node = unavailableNodes.find((candidate) => {
38843
- const base = `terminal-camera-${candidate.id}`;
38844
- return row.stableId === base || row.stableId.startsWith(`${base}-`);
38845
- });
38846
- if (!node || cameraDeclarations.some((camera) => camera.stableId === row.stableId)) continue;
38847
- const base = `terminal-camera-${node.id}`;
38848
- const profileId = row.stableId === base ? "monitor" : row.stableId.slice(base.length + 1);
38849
- const profileLabel = profileId === "monitor" ? "BTM" : profileId;
38850
- cameraDeclarations.push({
38851
- stableId: row.stableId,
38852
- name: `Terminal ${profileLabel} - ${node.isHub ? "Hub" : node.name}`,
38853
- config: {
38854
- nodeId: node.id,
38855
- profileId,
38856
- profileLabel
38857
- }
38858
- });
38859
- }
38860
- }
39605
+ const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
39606
+ const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
38861
39607
  const result = await new DeclaredDevices({
38862
39608
  logger: this.ctx.logger.child("camera-declaration"),
38863
39609
  addonId: this.ctx.id,
38864
39610
  devices: this.ctx.kernel.devices,
38865
39611
  localNodeId: this.ctx.kernel.localNodeId,
38866
- getIntegration: async (addonId) => this.ctx.api.integrations.getByAddonId.query({ addonId }),
38867
- createIntegration: async (input) => this.ctx.api.integrations.create.mutate(input),
39612
+ getIntegration: async (addonId) => {
39613
+ const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
39614
+ terminalIntegrationId = integration?.id ?? null;
39615
+ return integration;
39616
+ },
39617
+ createIntegration: async (input) => {
39618
+ const integration = await this.ctx.api.integrations.create.mutate(input);
39619
+ terminalIntegrationId = integration.id;
39620
+ return integration;
39621
+ },
38868
39622
  updateIntegration: async ({ id, info }) => {
38869
39623
  await this.ctx.api.integrations.update.mutate({
38870
39624
  id,
@@ -38872,7 +39626,9 @@ var TerminalAddon = class extends BaseAddon {
38872
39626
  skipRestart: true
38873
39627
  });
38874
39628
  },
38875
- listOwnDevices: async () => this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id })
39629
+ listOwnDevices: async () => {
39630
+ return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
39631
+ }
38876
39632
  }).reconcile({
38877
39633
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38878
39634
  placement: "hub",
@@ -38885,12 +39641,176 @@ var TerminalAddon = class extends BaseAddon {
38885
39641
  role: "terminal-camera"
38886
39642
  }))
38887
39643
  });
38888
- const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
39644
+ const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
38889
39645
  for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
39646
+ const declaration = declarationsByStableId.get(outcome.stableId);
39647
+ if (declaration) {
39648
+ const config = outcome.device.config;
39649
+ 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)) {
39650
+ await config.setAll(declaration.config);
39651
+ this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39652
+ }
39653
+ }
39654
+ if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
38890
39655
  const nodeId = outcome.device.config.get("nodeId");
38891
- outcome.device.setNodeOnline(onlineByNode.get(nodeId) ?? false);
39656
+ const profileId = outcome.device.config.get("profileId");
39657
+ const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39658
+ outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
39659
+ }
39660
+ }
39661
+ /**
39662
+ * A Terminal camera is a rendered screen. Object detection on it finds
39663
+ * nothing, forever, at full cost.
39664
+ *
39665
+ * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
39666
+ * through the detection pipeline at ~61 ms of inference each, plus 115
39667
+ * capture-scheduler requests a minute — against `detections=0`. Multiply by
39668
+ * one terminal per node and it is a standing tax on a hub that was already
39669
+ * shedding 86 % of its capture queue.
39670
+ *
39671
+ * Written through `setCameraSwitch`, which is the authority that already owns
39672
+ * this function — [D62] forbids a second store that disagrees with it. And
39673
+ * written ONLY on creation: an operator who deliberately turns detection back
39674
+ * on for a terminal must win, and a reconcile that re-asserted every pass
39675
+ * would silently overrule them once a minute.
39676
+ */
39677
+ async silenceAnalysisFor(deviceId) {
39678
+ for (const switchId of ["object-detection", "audio-analysis"]) try {
39679
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
39680
+ deviceId,
39681
+ switchId,
39682
+ enabled: false
39683
+ });
39684
+ } catch (err) {
39685
+ this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
39686
+ tags: { deviceId },
39687
+ meta: {
39688
+ switchId,
39689
+ error: err instanceof Error ? err.message : String(err)
39690
+ }
39691
+ });
38892
39692
  }
38893
39693
  }
39694
+ terminalInstanceControl() {
39695
+ return {
39696
+ listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
39697
+ createInstance: async (input) => this.createTerminalInstance(input),
39698
+ deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
39699
+ setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
39700
+ listLegacyCameras: async () => this.listLegacyTerminalCameras(),
39701
+ adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
39702
+ };
39703
+ }
39704
+ terminalInstances() {
39705
+ return readTerminalInstances(this.config.terminalInstances, (message) => {
39706
+ this.ctx.logger.warn(message);
39707
+ });
39708
+ }
39709
+ instanceInfo(instance) {
39710
+ return {
39711
+ instanceId: instance.id,
39712
+ cameraStableId: instance.cameraStableId,
39713
+ nodeId: instance.nodeId,
39714
+ profileId: instance.profileId,
39715
+ profileLabel: instance.profileLabel,
39716
+ name: instance.name,
39717
+ enabled: instance.enabled
39718
+ };
39719
+ }
39720
+ replaceTerminalCameraTombstones(stableIds) {
39721
+ this.terminalCameraTombstones.clear();
39722
+ for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
39723
+ }
39724
+ async createTerminalInstance(input) {
39725
+ const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
39726
+ await this.reconcileTerminalCameras();
39727
+ return instance;
39728
+ }
39729
+ async createTerminalInstanceUnlocked(input) {
39730
+ const relay = this.cameraRelay;
39731
+ if (!relay) throw new Error("Terminal instances are managed on the hub");
39732
+ const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
39733
+ if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
39734
+ const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
39735
+ if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
39736
+ const id = crypto.randomUUID();
39737
+ const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
39738
+ const instance = {
39739
+ id,
39740
+ cameraStableId: newTerminalCameraStableId(id),
39741
+ nodeId: input.targetNodeId,
39742
+ profileId: profile.profileId,
39743
+ profileLabel: profile.label,
39744
+ name,
39745
+ enabled: true
39746
+ };
39747
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39748
+ return this.instanceInfo(instance);
39749
+ }
39750
+ async deleteTerminalInstance(instanceId) {
39751
+ await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
39752
+ await this.reconcileTerminalCameras();
39753
+ }
39754
+ async deleteTerminalInstanceUnlocked(instanceId) {
39755
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39756
+ if (!instance) return;
39757
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39758
+ await this.cameraRelay?.closeInstance(instance.id);
39759
+ await this.updateGlobalSettings({
39760
+ terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
39761
+ terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
39762
+ });
39763
+ }
39764
+ async setTerminalInstanceEnabled(instanceId, enabled) {
39765
+ const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
39766
+ await this.reconcileTerminalCameras();
39767
+ return instance;
39768
+ }
39769
+ async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
39770
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39771
+ if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
39772
+ if (!enabled) {
39773
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39774
+ await this.cameraRelay?.closeInstance(instance.id);
39775
+ }
39776
+ const updated = {
39777
+ ...instance,
39778
+ enabled
39779
+ };
39780
+ const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
39781
+ const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
39782
+ await this.updateGlobalSettings({
39783
+ terminalInstances,
39784
+ terminalCameraTombstones
39785
+ });
39786
+ return this.instanceInfo(updated);
39787
+ }
39788
+ async listLegacyTerminalCameras() {
39789
+ const instances = this.terminalInstances();
39790
+ const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
39791
+ return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
39792
+ }
39793
+ async adoptLegacyMonitor(stableId, requestedName) {
39794
+ const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
39795
+ await this.reconcileTerminalCameras();
39796
+ return instance;
39797
+ }
39798
+ async adoptLegacyMonitorUnlocked(stableId, requestedName) {
39799
+ if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
39800
+ const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
39801
+ if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
39802
+ const instance = {
39803
+ id: crypto.randomUUID(),
39804
+ cameraStableId: legacy.stableId,
39805
+ nodeId: legacy.nodeId,
39806
+ profileId: "monitor",
39807
+ profileLabel: legacy.profileLabel,
39808
+ name: requestedName?.trim() || legacy.name,
39809
+ enabled: true
39810
+ };
39811
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39812
+ return this.instanceInfo(instance);
39813
+ }
38894
39814
  globalSettingsSchema() {
38895
39815
  return this.schema({ sections: [{
38896
39816
  id: "terminal",
@@ -39001,7 +39921,7 @@ var TerminalAddon = class extends BaseAddon {
39001
39921
  }, {
39002
39922
  id: "terminal-profiles",
39003
39923
  title: "Custom profiles",
39004
- description: "Each enabled profile creates its own Terminal camera on this node and is available from the Terminal page. Session requests only carry the profile ID; commands cannot be overridden by clients.",
39924
+ 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.",
39005
39925
  columns: 1,
39006
39926
  fields: [this.field({
39007
39927
  type: "editable-array",