@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.mjs CHANGED
@@ -7605,12 +7605,11 @@ var RecordingConfigSchema = object({
7605
7605
  /**
7606
7606
  * Entity-relocation job state (storage entity-routing spec, Phase 4).
7607
7607
  *
7608
- * One shape shared by the recorder's `relocateFootage` (segments) and
7609
- * pipeline-analytics' `relocateMedia` (event media blobs) so the admin Data
7610
- * page renders both movers with one component. Jobs are in-RAM (a restart
7611
- * forgets them re-running is safe by construction: copy-if-absent, delete
7612
- * after verify) and each completed/failed run also lands one durable ops-log
7613
- * row on the owning addon's surface.
7608
+ * One shape shared by the recorder and pipeline-analytics internal movers.
7609
+ * The public admin surface is `storage-migration`; child jobs remain in RAM
7610
+ * because copy-if-absent, verify, delete and index/row repoint are resumable.
7611
+ * Each completed/failed run also lands one durable ops-log row on its owning
7612
+ * addon surface.
7614
7613
  */
7615
7614
  var RelocateJobStateSchema = _enum([
7616
7615
  "running",
@@ -7637,19 +7636,100 @@ var RelocateJobSchema = object({
7637
7636
  finishedAt: number().nullable(),
7638
7637
  error: string().nullable()
7639
7638
  });
7639
+ /** Profile-derived footage selection used only by the migration coordinator:
7640
+ * `recordings` owns high+mid; `recordingsLow` owns low. */
7641
+ var RelocateFootageClassSchema = _enum(["recordings", "recordingsLow"]);
7640
7642
  var RelocateFootageInputSchema = object({
7641
- deviceId: number().optional(),
7642
7643
  fromLocationId: string(),
7643
7644
  toLocationId: string(),
7644
7645
  entities: array(_enum(["segments"])).optional(),
7646
+ /** Limits relocation to the logical profile class. Omit only for the
7647
+ * pre-orchestration compatibility path. */
7648
+ footageClass: RelocateFootageClassSchema.optional(),
7645
7649
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7646
7650
  * never allowed to starve live writers. */
7647
7651
  throttleMbps: number().min(1).max(1e3).optional()
7648
7652
  });
7649
- var RelocateMediaInputSchema = object({
7650
- deviceId: number().optional(),
7653
+ /** Internal, lease-scoped participant operation. It is intentionally separate
7654
+ * from persistent recording settings: a migration never changes
7655
+ * `RecordingConfig.enabled` or camera wrapper bindings. */
7656
+ var StorageMigrationLeaseInputSchema = object({ leaseId: string().min(1) });
7657
+ var StorageMigrationFootageMoveInputSchema = RelocateFootageInputSchema.extend({ leaseId: string().min(1) });
7658
+ var StorageMigrationMediaMoveInputSchema = object({
7651
7659
  toLocationId: string(),
7652
7660
  throttleMbps: number().min(1).max(1e3).optional()
7661
+ }).extend({ leaseId: string().min(1) });
7662
+ /** The independently selectable logical storage classes. `recordings`
7663
+ * encompasses the high and mid segment profiles; `recordingsLow` is low
7664
+ * segments; `eventMedia` is post-analysis blobs. */
7665
+ var StorageMigrationClassSchema = _enum([
7666
+ "recordings",
7667
+ "recordingsLow",
7668
+ "eventMedia"
7669
+ ]);
7670
+ /** A destination is always an existing, fully-qualified location id. The
7671
+ * migration API intentionally never changes a source location's `basePath`:
7672
+ * callers create a new `<type>:<slug>` location, then select it here. */
7673
+ var StorageMigrationDestinationsSchema = object({
7674
+ recordings: string().min(1).optional(),
7675
+ recordingsLow: string().min(1).optional(),
7676
+ eventMedia: string().min(1).optional()
7677
+ }).refine((value) => Object.keys(value).length > 0, { message: "select at least one storage class" });
7678
+ /** Shared input for planning and starting an orchestrated storage migration. */
7679
+ var StorageMigrationInputSchema = object({
7680
+ destinations: StorageMigrationDestinationsSchema,
7681
+ throttleMbps: number().min(1).max(1e3).optional()
7682
+ });
7683
+ /** The durable coordinator state machine. The only phase that changes default
7684
+ * locations is `repointing`, after every selected mover has completed and been
7685
+ * verified. */
7686
+ var StorageMigrationPhaseSchema = _enum([
7687
+ "planning",
7688
+ "pausing",
7689
+ "moving",
7690
+ "verifying",
7691
+ "repointing",
7692
+ "refreshing",
7693
+ "resuming",
7694
+ "done",
7695
+ "failed",
7696
+ "cancelled"
7697
+ ]);
7698
+ var StorageMigrationParticipantSchema = _enum([
7699
+ "pipeline",
7700
+ "recorder",
7701
+ "analytics"
7702
+ ]);
7703
+ var StorageMigrationMoveSchema = object({
7704
+ storageClass: StorageMigrationClassSchema,
7705
+ fromLocationId: string(),
7706
+ toLocationId: string(),
7707
+ moverJobId: string().nullable(),
7708
+ state: RelocateJobStateSchema.nullable(),
7709
+ error: string().nullable()
7710
+ });
7711
+ var StorageMigrationJobSchema = object({
7712
+ jobId: string(),
7713
+ phase: StorageMigrationPhaseSchema,
7714
+ destinations: StorageMigrationDestinationsSchema,
7715
+ throttleMbps: number(),
7716
+ moves: array(StorageMigrationMoveSchema),
7717
+ pauseLeaseId: string().nullable(),
7718
+ pausedParticipants: array(StorageMigrationParticipantSchema),
7719
+ repointed: boolean(),
7720
+ cancelRequested: boolean(),
7721
+ startedAt: number(),
7722
+ updatedAt: number(),
7723
+ finishedAt: number().nullable(),
7724
+ error: string().nullable()
7725
+ });
7726
+ var StorageMigrationPlanSchema = object({
7727
+ destinations: StorageMigrationDestinationsSchema,
7728
+ moves: array(object({
7729
+ storageClass: StorageMigrationClassSchema,
7730
+ fromLocationId: string(),
7731
+ toLocationId: string()
7732
+ }))
7653
7733
  });
7654
7734
  /**
7655
7735
  * `StorageLocationType` — an addon-declared id that identifies the *kind* of
@@ -16248,13 +16328,19 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
16248
16328
  }), method(object({ deviceId: number() }), EventPruneCountsSchema, {
16249
16329
  kind: "mutation",
16250
16330
  auth: "admin"
16251
- }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
16331
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
16252
16332
  kind: "mutation",
16253
16333
  auth: "admin"
16254
- }), method(object({}), array(RelocateJobSchema).readonly(), {
16255
- kind: "query",
16334
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
16335
+ kind: "mutation",
16256
16336
  auth: "admin"
16257
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16337
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
16338
+ kind: "mutation",
16339
+ auth: "admin"
16340
+ }), method(StorageMigrationMediaMoveInputSchema, object({ jobId: string() }), {
16341
+ kind: "mutation",
16342
+ auth: "admin"
16343
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
16258
16344
  kind: "mutation",
16259
16345
  auth: "admin"
16260
16346
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
@@ -17784,7 +17870,13 @@ var NodeInferenceDevicesSchema = object({
17784
17870
  reachable: boolean(),
17785
17871
  devices: array(NodeInferenceDeviceSchema).readonly()
17786
17872
  });
17787
- method(object({
17873
+ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
17874
+ kind: "mutation",
17875
+ auth: "admin"
17876
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
17877
+ kind: "mutation",
17878
+ auth: "admin"
17879
+ }), method(object({
17788
17880
  deviceId: number(),
17789
17881
  agentNodeId: string()
17790
17882
  }), object({ success: literal(true) }), {
@@ -18224,7 +18316,33 @@ var SnapshotImageSchema = object({
18224
18316
  base64: string(),
18225
18317
  contentType: string()
18226
18318
  });
18227
- object({
18319
+ /**
18320
+ * snapshot — device-scoped capability for camera image capture.
18321
+ *
18322
+ * Two kinds of providers coexist behind this cap name:
18323
+ *
18324
+ * - **Native** providers (kind:'native'): registered per-device by
18325
+ * device-driver addons (RtspCamera, OnvifCamera, …) via
18326
+ * `DeviceContext.registerNativeCap`. Each knows how to fetch a frame
18327
+ * straight from the camera (HTTP snapshot URL, ONVIF action, etc.).
18328
+ *
18329
+ * - **Wrapper** providers (kind:'wrapper'): register as a system
18330
+ * provider (SnapshotAddon in `@camstack/system/builtins/snapshot`). The
18331
+ * wrapper owns the cache and invokes the native via
18332
+ * `ctx.getNativeProvider(snapshotCapability, deviceId)` on miss.
18333
+ *
18334
+ * Device-scoped routing: callers use `ctx.fetchDevice(id).snapshot.*`;
18335
+ * the DeviceProxy auto-injects `deviceId` + `nodeId` and dispatches to
18336
+ * the provider currently active for that device (wrapper wins when
18337
+ * activated via `setWrapperActive`, otherwise the native).
18338
+ */
18339
+ /**
18340
+ * Live readable snapshot state — diagnostic info that a consumer can
18341
+ * pull to know when the last image was captured, how stale the cache
18342
+ * is, and which stream was used. Distinct from `getSnapshot` which
18343
+ * returns the JPEG itself.
18344
+ */
18345
+ var SnapshotStatusSchema = object({
18228
18346
  /** Ms epoch of the last successful capture. Null if none yet. */
18229
18347
  lastCapturedAt: number().nullable(),
18230
18348
  /** Age of the cached image in ms. Null if no cache. */
@@ -18234,64 +18352,133 @@ object({
18234
18352
  /** Stream id used for the last capture ('high'|'mid'|'low' or custom). Null if via HTTP endpoint. */
18235
18353
  lastStreamId: string().nullable()
18236
18354
  });
18237
- DeviceType.Camera, method(object({
18238
- deviceId: number(),
18239
- streamId: string().optional(),
18240
- /**
18241
- * Bypass the cache freshness check and fetch directly from the
18242
- * native (or stream-broker fallback). Triggered by the UI's
18243
- * "refresh" button so an operator can force a fresh frame
18244
- * even when the cache is well within the device's
18245
- * `snapshotMaxAgeS` window.
18246
- *
18247
- * **`force` is an OPERATOR signal, not a freshness preference.** On a
18248
- * battery camera it is the one thing that walks past the wrapper's
18249
- * sleep gate and wakes the camera, so a background caller — a poller,
18250
- * an event handler, a thumbnail — must NEVER set it. Every such caller
18251
- * gets the cached frame, which on a sleeping battery camera is the
18252
- * correct answer: stale but honest beats woken.
18253
- */
18254
- force: boolean().optional()
18255
- }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
18256
- kind: "mutation",
18257
- auth: "admin"
18258
- }), systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
18259
- deviceId: number(),
18260
- lastCapturedAt: number().nullable(),
18261
- cacheAgeMs: number().nullable(),
18262
- etag: string().nullable()
18263
- }))), systemMethod(object({
18264
- /** The tiles a surface is actually rendering. One entry per (device,
18265
- * width) the caller will paint — the width is snapped to the server's
18266
- * ladder and becomes part of the link's SIGNED identity. */
18355
+ var snapshotCapability = {
18356
+ name: "snapshot",
18357
+ scope: "device",
18358
+ deviceNative: true,
18359
+ mode: "singleton",
18360
+ kind: "wrapper",
18361
+ defaultActive: true,
18362
+ deviceTypes: [DeviceType.Camera],
18363
+ exposesDeviceSettings: true,
18364
+ methods: {
18365
+ getSnapshot: method(object({
18366
+ deviceId: number(),
18367
+ streamId: string().optional(),
18368
+ /**
18369
+ * Bypass the cache freshness check and fetch directly from the
18370
+ * native (or stream-broker fallback). Triggered by the UI's
18371
+ * "refresh" button so an operator can force a fresh frame
18372
+ * even when the cache is well within the device's
18373
+ * `snapshotMaxAgeS` window.
18374
+ *
18375
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
18376
+ * battery camera it is the one thing that walks past the wrapper's
18377
+ * sleep gate and wakes the camera, so a background caller — a poller,
18378
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
18379
+ * gets the cached frame, which on a sleeping battery camera is the
18380
+ * correct answer: stale but honest beats woken.
18381
+ */
18382
+ force: boolean().optional()
18383
+ }), SnapshotImageSchema.nullable()),
18384
+ invalidateCache: method(object({ deviceId: number() }), _void(), {
18385
+ kind: "mutation",
18386
+ auth: "admin"
18387
+ }),
18388
+ /**
18389
+ * Cache-only batch overview — answers from the wrapper's in-memory cache in
18390
+ * O(n) and NEVER triggers a capture. Lets a grid skip requesting images for
18391
+ * devices that never produced a frame, and gives it an ETag per device for
18392
+ * conditional (304-able) image fetches. `lastCapturedAt`/`cacheAgeMs`/`etag`
18393
+ * are null for a device with no cached frame.
18394
+ */
18395
+ getSnapshotOverview: systemMethod(object({ deviceIds: array(number()).min(1).max(200) }), array(object({
18396
+ deviceId: number(),
18397
+ lastCapturedAt: number().nullable(),
18398
+ cacheAgeMs: number().nullable(),
18399
+ etag: string().nullable()
18400
+ }))),
18401
+ /**
18402
+ * Signed, expiring links to a CLIENT-SIZED frame — and the demand signal
18403
+ * that makes those frames current.
18404
+ *
18405
+ * ## The problem it replaces
18406
+ *
18407
+ * `getSnapshotOverview` is cache-only by contract: it answers from whatever
18408
+ * the wrapper happens to hold and never captures. Under D93 the client
18409
+ * versions its image URL on that answer, and an image REQUEST was the only
18410
+ * demand signal. Both of those are satisfiable by the client's own image
18411
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
18412
+ * in a previous session comes off disk with no network, no demand, and no
18413
+ * capture. Measured on the live hub: reopening after two minutes idle
18414
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
18415
+ * fleet only recovered because a later poll happened to observe a different
18416
+ * identity.
18417
+ *
18418
+ * ## The two properties that fix it
18419
+ *
18420
+ * **It is an RPC, so no client cache can answer it.** The demand signal
18421
+ * always reaches the wrapper. This method therefore CAPTURES, where
18422
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
18423
+ * newer" but that the overview poll is app-wide (a capturing overview would
18424
+ * dial every camera on the install) while this is called by a rendered
18425
+ * surface naming the tiles it is actually painting, at the width it is
18426
+ * painting them.
18427
+ *
18428
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
18429
+ * server-side keep-warm loop was removed (operator directive — on-demand,
18430
+ * always), so a camera nobody is looking at costs nothing at all.
18431
+ *
18432
+ * **It waits, briefly and boundedly, for the capture it triggered.** The
18433
+ * returned `capturedAt` is the frame the link will serve, not the frame the
18434
+ * cache held when the client asked, so a first paint is honest and current
18435
+ * instead of a generation behind. A device that does not settle inside the
18436
+ * bound still gets a link and its real (older) `capturedAt` — the next poll
18437
+ * carries it forward.
18438
+ *
18439
+ * `force` is never set on behalf of a client here. A sleeping battery camera
18440
+ * is reported with `sleeping: true` and the last frame it produced, however
18441
+ * old; the wrapper's existing sleep gate owns that decision and this method
18442
+ * adds no second one.
18443
+ */
18444
+ getSnapshotLinks: systemMethod(object({
18445
+ /** The tiles a surface is actually rendering. One entry per (device,
18446
+ * width) the caller will paint — the width is snapped to the server's
18447
+ * ladder and becomes part of the link's SIGNED identity. */
18267
18448
  targets: array(object({
18268
- deviceId: number(),
18269
- /** Target width in px. Omit for the frame as captured — correct
18270
- * for a full-bleed surface, wrong (and expensive) for a grid. */
18271
- width: number().int().positive().optional()
18272
- })).min(1).max(200) }), array(object({
18273
- deviceId: number(),
18274
- /** Root-relative signed path, or null when the link plane is not
18275
- * served (no data-plane facility). Present even for a device that has
18276
- * never captured — the request is what triggers the first one (D94). */
18277
- url: string().nullable(),
18278
- /** Epoch ms of the frame this link serves. Null = never captured.
18279
- * THE honest age: the tRPC path carried none before this. */
18280
- capturedAt: number().nullable(),
18281
- /** Age of that frame at the moment the answer was built. */
18282
- ageMs: number().nullable(),
18283
- /** Epoch ms after which `url` stops verifying. */
18284
- expiresAt: number().nullable(),
18285
- /** Ladder rung the bytes are at; null = the frame as captured. */
18286
- width: number().nullable(),
18287
- /** The device has never produced a frame. An empty state, not a
18288
- * failure — and never a reason to withhold the link (D94). */
18289
- neverCaptured: boolean(),
18290
- /** A sleeping battery camera: the frame is deliberately stale and will
18291
- * NOT refresh in the background. A surface should say so rather than
18292
- * present it as current. */
18293
- sleeping: boolean()
18294
- })));
18449
+ deviceId: number(),
18450
+ /** Target width in px. Omit for the frame as captured — correct
18451
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18452
+ width: number().int().positive().optional()
18453
+ })).min(1).max(200) }), array(object({
18454
+ deviceId: number(),
18455
+ /** Root-relative signed path, or null when the link plane is not
18456
+ * served (no data-plane facility). Present even for a device that has
18457
+ * never captured — the request is what triggers the first one (D94). */
18458
+ url: string().nullable(),
18459
+ /** Epoch ms of the frame this link serves. Null = never captured.
18460
+ * THE honest age: the tRPC path carried none before this. */
18461
+ capturedAt: number().nullable(),
18462
+ /** Age of that frame at the moment the answer was built. */
18463
+ ageMs: number().nullable(),
18464
+ /** Epoch ms after which `url` stops verifying. */
18465
+ expiresAt: number().nullable(),
18466
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18467
+ width: number().nullable(),
18468
+ /** The device has never produced a frame. An empty state, not a
18469
+ * failure — and never a reason to withhold the link (D94). */
18470
+ neverCaptured: boolean(),
18471
+ /** A sleeping battery camera: the frame is deliberately stale and will
18472
+ * NOT refresh in the background. A surface should say so rather than
18473
+ * present it as current. */
18474
+ sleeping: boolean()
18475
+ })))
18476
+ },
18477
+ status: {
18478
+ schema: SnapshotStatusSchema,
18479
+ kind: "poll"
18480
+ }
18481
+ };
18295
18482
  /**
18296
18483
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
18297
18484
  * providers (OIDC, SAML, magic-link, …) mint an HMAC-signed token
@@ -18458,6 +18645,13 @@ method(object({ locationId: string() }), EvictableUsageSchema), method(object({
18458
18645
  locationId: string(),
18459
18646
  targetBytes: number().int().positive()
18460
18647
  }), EvictResultSchema, { kind: "mutation" });
18648
+ method(StorageMigrationInputSchema, StorageMigrationPlanSchema, { auth: "admin" }), method(StorageMigrationInputSchema, object({ jobId: string() }), {
18649
+ kind: "mutation",
18650
+ auth: "admin"
18651
+ }), method(object({ jobId: string().optional() }), StorageMigrationJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
18652
+ kind: "mutation",
18653
+ auth: "admin"
18654
+ });
18461
18655
  var ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18462
18656
  providerId: string().min(1),
18463
18657
  displayName: string().min(1),
@@ -18561,6 +18755,28 @@ var TerminalProfileInfoSchema = object({
18561
18755
  label: string(),
18562
18756
  description: string().optional()
18563
18757
  });
18758
+ /**
18759
+ * A durable operator-created Terminal instance. Profiles are templates; only
18760
+ * an instance declares a camera.
18761
+ */
18762
+ var TerminalInstanceInfoSchema = object({
18763
+ instanceId: string(),
18764
+ cameraStableId: string(),
18765
+ nodeId: string(),
18766
+ profileId: string(),
18767
+ profileLabel: string(),
18768
+ name: string(),
18769
+ enabled: boolean()
18770
+ });
18771
+ var TerminalLegacyCameraSchema = object({
18772
+ stableId: string(),
18773
+ nodeId: string(),
18774
+ profileId: string(),
18775
+ profileLabel: string(),
18776
+ name: string(),
18777
+ /** Only legacy monitor cameras can retain their historic stable identity. */
18778
+ adoptable: boolean()
18779
+ });
18564
18780
  var TerminalOutputEventSchema = discriminatedUnion("kind", [object({
18565
18781
  seq: number().int().positive(),
18566
18782
  kind: literal("data"),
@@ -18580,10 +18796,9 @@ var TerminalOutputBatchSchema = object({
18580
18796
  /**
18581
18797
  * terminal-session — singleton system capability for interactive TTY sessions.
18582
18798
  *
18583
- * Phase 1 (this cap): open/resize/close/list a pty running an allowlisted
18584
- * profile, streamed to xterm.js over the data plane. Phase 2 (streaming a
18585
- * session as a camera) is a separate device-provider concern and does not
18586
- * change this contract.
18799
+ * Owns both live PTY lifecycle and durable Terminal instance management.
18800
+ * Profiles are allowlisted templates; an explicit instance is the only path
18801
+ * that declares a camera.
18587
18802
  */
18588
18803
  var terminalSessionCapability = {
18589
18804
  name: "terminal-session",
@@ -18592,6 +18807,37 @@ var terminalSessionCapability = {
18592
18807
  methods: {
18593
18808
  /** Pre-declared profiles the operator may open. */
18594
18809
  listProfiles: method(_void(), array(TerminalProfileInfoSchema).readonly(), { auth: "admin" }),
18810
+ /** Explicit durable Terminal instances, managed centrally on the hub. */
18811
+ listInstances: method(_void(), array(TerminalInstanceInfoSchema).readonly(), { auth: "admin" }),
18812
+ createInstance: method(object({
18813
+ targetNodeId: string().min(1),
18814
+ profileId: string().min(1),
18815
+ name: string().trim().min(1).max(160).optional()
18816
+ }), TerminalInstanceInfoSchema, {
18817
+ kind: "mutation",
18818
+ auth: "admin"
18819
+ }),
18820
+ deleteInstance: method(object({ instanceId: string().min(1) }), _void(), {
18821
+ kind: "mutation",
18822
+ auth: "admin"
18823
+ }),
18824
+ setInstanceEnabled: method(object({
18825
+ instanceId: string().min(1),
18826
+ enabled: boolean()
18827
+ }), TerminalInstanceInfoSchema, {
18828
+ kind: "mutation",
18829
+ auth: "admin"
18830
+ }),
18831
+ /** Existing automatic cameras are shown for explicit migration only. */
18832
+ listLegacyCameras: method(_void(), array(TerminalLegacyCameraSchema).readonly(), { auth: "admin" }),
18833
+ /** Explicitly adopt one legacy monitor camera, retaining its stable id. */
18834
+ adoptLegacyMonitor: method(object({
18835
+ stableId: string().min(1),
18836
+ name: string().trim().min(1).max(160).optional()
18837
+ }), TerminalInstanceInfoSchema, {
18838
+ kind: "mutation",
18839
+ auth: "admin"
18840
+ }),
18595
18841
  /** Live sessions currently hosted by the provider. */
18596
18842
  listSessions: method(_void(), array(TerminalSessionInfoSchema).readonly(), { auth: "admin" }),
18597
18843
  /**
@@ -18623,7 +18869,13 @@ var terminalSessionCapability = {
18623
18869
  pullOutput: method(object({
18624
18870
  sessionId: string(),
18625
18871
  afterSeq: number().int().nonnegative(),
18626
- waitMs: number().int().min(0).max(2e3).default(0)
18872
+ waitMs: number().int().min(0).max(2e3).default(0),
18873
+ /**
18874
+ * Wait when a just-opened session has no output yet. Kept opt-in so a
18875
+ * browser's initial repaint remains immediate; the camera snapshot
18876
+ * relay uses it to avoid encoding a blank startup frame.
18877
+ */
18878
+ waitForOutput: boolean().optional()
18627
18879
  }), TerminalOutputBatchSchema, {
18628
18880
  kind: "mutation",
18629
18881
  auth: "admin",
@@ -24602,13 +24854,19 @@ method(object({
24602
24854
  }), {
24603
24855
  kind: "mutation",
24604
24856
  auth: "admin"
24605
- }), method(RelocateFootageInputSchema, object({ jobId: string() }), {
24857
+ }), method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
24606
24858
  kind: "mutation",
24607
24859
  auth: "admin"
24608
- }), method(object({}), array(RelocateJobSchema).readonly(), {
24609
- kind: "query",
24860
+ }), method(StorageMigrationLeaseInputSchema, object({ resumed: literal(true) }), {
24861
+ kind: "mutation",
24862
+ auth: "admin"
24863
+ }), method(StorageMigrationLeaseInputSchema, object({ refreshed: literal(true) }), {
24864
+ kind: "mutation",
24610
24865
  auth: "admin"
24611
- }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24866
+ }), method(StorageMigrationFootageMoveInputSchema, object({ jobId: string() }), {
24867
+ kind: "mutation",
24868
+ auth: "admin"
24869
+ }), method(object({ jobId: string() }), RelocateJobSchema.nullable(), { auth: "admin" }), method(object({ jobId: string() }), object({ cancelled: boolean() }), {
24612
24870
  kind: "mutation",
24613
24871
  auth: "admin"
24614
24872
  });
@@ -27138,9 +27396,10 @@ var DeclaredDevices = class {
27138
27396
  }
27139
27397
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27140
27398
  const index = await this.readIndex();
27399
+ const live = await this.readLiveByStableId();
27141
27400
  const outcomes = [];
27142
27401
  for (const declaration of spec.devices) {
27143
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27402
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27144
27403
  if (outcome !== null) outcomes.push(outcome);
27145
27404
  }
27146
27405
  return {
@@ -27186,6 +27445,26 @@ var DeclaredDevices = class {
27186
27445
  return new Map(rows.map((row) => [row.stableId, row]));
27187
27446
  }
27188
27447
  /**
27448
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27449
+ *
27450
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27451
+ * is persisted rows, this is live objects. A row without an object must be
27452
+ * adopted; an object must be left exactly as it is.
27453
+ *
27454
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27455
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27456
+ * genuinely needs bringing up.
27457
+ */
27458
+ async readLiveByStableId() {
27459
+ try {
27460
+ const devices = await this.ports.devices.getAll();
27461
+ return new Map(devices.map((device) => [device.stableId, device]));
27462
+ } catch (err) {
27463
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27464
+ return /* @__PURE__ */ new Map();
27465
+ }
27466
+ }
27467
+ /**
27189
27468
  * One declaration: adopt what exists, create what does not.
27190
27469
  *
27191
27470
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27194,8 +27473,15 @@ var DeclaredDevices = class {
27194
27473
  * the declared name over the operator's rename. D49: that branch needs a
27195
27474
  * second read to agree.
27196
27475
  */
27197
- async applyDeclaration(declaration, integrationId, index) {
27476
+ async applyDeclaration(declaration, integrationId, index, live) {
27198
27477
  try {
27478
+ const alreadyLive = live.get(declaration.stableId);
27479
+ if (alreadyLive !== void 0) return {
27480
+ stableId: declaration.stableId,
27481
+ deviceId: alreadyLive.id,
27482
+ device: alreadyLive,
27483
+ created: false
27484
+ };
27199
27485
  let existing = index.get(declaration.stableId);
27200
27486
  if (existing === void 0) {
27201
27487
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -30300,7 +30586,7 @@ Object.freeze({
30300
30586
  addonId: null,
30301
30587
  access: "create"
30302
30588
  },
30303
- "pipelineAnalytics.cancelMediaRelocate": {
30589
+ "pipelineAnalytics.cancelStorageMigrationMove": {
30304
30590
  capName: "pipeline-analytics",
30305
30591
  capScope: "device",
30306
30592
  addonId: null,
@@ -30372,12 +30658,6 @@ Object.freeze({
30372
30658
  addonId: null,
30373
30659
  access: "view"
30374
30660
  },
30375
- "pipelineAnalytics.getMediaRelocateStatus": {
30376
- capName: "pipeline-analytics",
30377
- capScope: "device",
30378
- addonId: null,
30379
- access: "view"
30380
- },
30381
30661
  "pipelineAnalytics.getMotionEvents": {
30382
30662
  capName: "pipeline-analytics",
30383
30663
  capScope: "device",
@@ -30414,6 +30694,12 @@ Object.freeze({
30414
30694
  addonId: null,
30415
30695
  access: "view"
30416
30696
  },
30697
+ "pipelineAnalytics.getStorageMigrationMoveStatus": {
30698
+ capName: "pipeline-analytics",
30699
+ capScope: "device",
30700
+ addonId: null,
30701
+ access: "view"
30702
+ },
30417
30703
  "pipelineAnalytics.getTrack": {
30418
30704
  capName: "pipeline-analytics",
30419
30705
  capScope: "device",
@@ -30492,6 +30778,12 @@ Object.freeze({
30492
30778
  addonId: null,
30493
30779
  access: "view"
30494
30780
  },
30781
+ "pipelineAnalytics.pauseForStorageMigration": {
30782
+ capName: "pipeline-analytics",
30783
+ capScope: "device",
30784
+ addonId: null,
30785
+ access: "create"
30786
+ },
30495
30787
  "pipelineAnalytics.proposeRetrainAnnotations": {
30496
30788
  capName: "pipeline-analytics",
30497
30789
  capScope: "device",
@@ -30522,7 +30814,7 @@ Object.freeze({
30522
30814
  addonId: null,
30523
30815
  access: "create"
30524
30816
  },
30525
- "pipelineAnalytics.relocateMedia": {
30817
+ "pipelineAnalytics.refreshStorageLocationsForMigration": {
30526
30818
  capName: "pipeline-analytics",
30527
30819
  capScope: "device",
30528
30820
  addonId: null,
@@ -30534,6 +30826,12 @@ Object.freeze({
30534
30826
  addonId: null,
30535
30827
  access: "create"
30536
30828
  },
30829
+ "pipelineAnalytics.resumeForStorageMigration": {
30830
+ capName: "pipeline-analytics",
30831
+ capScope: "device",
30832
+ addonId: null,
30833
+ access: "create"
30834
+ },
30537
30835
  "pipelineAnalytics.saveRetrainAnnotations": {
30538
30836
  capName: "pipeline-analytics",
30539
30837
  capScope: "device",
@@ -30558,6 +30856,12 @@ Object.freeze({
30558
30856
  addonId: null,
30559
30857
  access: "create"
30560
30858
  },
30859
+ "pipelineAnalytics.startStorageMigrationMove": {
30860
+ capName: "pipeline-analytics",
30861
+ capScope: "device",
30862
+ addonId: null,
30863
+ access: "create"
30864
+ },
30561
30865
  "pipelineAnalytics.wipeAllAnalytics": {
30562
30866
  capName: "pipeline-analytics",
30563
30867
  capScope: "device",
@@ -30924,6 +31228,12 @@ Object.freeze({
30924
31228
  addonId: null,
30925
31229
  access: "view"
30926
31230
  },
31231
+ "pipelineOrchestrator.pauseForStorageMigration": {
31232
+ capName: "pipeline-orchestrator",
31233
+ capScope: "system",
31234
+ addonId: null,
31235
+ access: "create"
31236
+ },
30927
31237
  "pipelineOrchestrator.rebalance": {
30928
31238
  capName: "pipeline-orchestrator",
30929
31239
  capScope: "system",
@@ -30948,6 +31258,12 @@ Object.freeze({
30948
31258
  addonId: null,
30949
31259
  access: "view"
30950
31260
  },
31261
+ "pipelineOrchestrator.resumeForStorageMigration": {
31262
+ capName: "pipeline-orchestrator",
31263
+ capScope: "system",
31264
+ addonId: null,
31265
+ access: "create"
31266
+ },
30951
31267
  "pipelineOrchestrator.saveTemplate": {
30952
31268
  capName: "pipeline-orchestrator",
30953
31269
  capScope: "system",
@@ -31344,7 +31660,7 @@ Object.freeze({
31344
31660
  addonId: null,
31345
31661
  access: "create"
31346
31662
  },
31347
- "recording.cancelRelocate": {
31663
+ "recording.cancelStorageMigrationMove": {
31348
31664
  capName: "recording",
31349
31665
  capScope: "system",
31350
31666
  addonId: null,
@@ -31380,7 +31696,7 @@ Object.freeze({
31380
31696
  addonId: null,
31381
31697
  access: "view"
31382
31698
  },
31383
- "recording.getRelocateStatus": {
31699
+ "recording.getStorageMigrationMoveStatus": {
31384
31700
  capName: "recording",
31385
31701
  capScope: "system",
31386
31702
  addonId: null,
@@ -31404,6 +31720,12 @@ Object.freeze({
31404
31720
  addonId: null,
31405
31721
  access: "view"
31406
31722
  },
31723
+ "recording.pauseForStorageMigration": {
31724
+ capName: "recording",
31725
+ capScope: "system",
31726
+ addonId: null,
31727
+ access: "create"
31728
+ },
31407
31729
  "recording.pruneFootage": {
31408
31730
  capName: "recording",
31409
31731
  capScope: "system",
@@ -31422,7 +31744,7 @@ Object.freeze({
31422
31744
  addonId: null,
31423
31745
  access: "view"
31424
31746
  },
31425
- "recording.relocateFootage": {
31747
+ "recording.refreshStorageLocationsForMigration": {
31426
31748
  capName: "recording",
31427
31749
  capScope: "system",
31428
31750
  addonId: null,
@@ -31446,12 +31768,24 @@ Object.freeze({
31446
31768
  addonId: null,
31447
31769
  access: "create"
31448
31770
  },
31771
+ "recording.resumeForStorageMigration": {
31772
+ capName: "recording",
31773
+ capScope: "system",
31774
+ addonId: null,
31775
+ access: "create"
31776
+ },
31449
31777
  "recording.setDeviceConfig": {
31450
31778
  capName: "recording",
31451
31779
  capScope: "system",
31452
31780
  addonId: null,
31453
31781
  access: "create"
31454
31782
  },
31783
+ "recording.startStorageMigrationMove": {
31784
+ capName: "recording",
31785
+ capScope: "system",
31786
+ addonId: null,
31787
+ access: "create"
31788
+ },
31455
31789
  "recordingExport.cancelExport": {
31456
31790
  capName: "recordingExport",
31457
31791
  capScope: "system",
@@ -31842,6 +32176,30 @@ Object.freeze({
31842
32176
  addonId: null,
31843
32177
  access: "view"
31844
32178
  },
32179
+ "storageMigration.cancel": {
32180
+ capName: "storage-migration",
32181
+ capScope: "system",
32182
+ addonId: null,
32183
+ access: "create"
32184
+ },
32185
+ "storageMigration.plan": {
32186
+ capName: "storage-migration",
32187
+ capScope: "system",
32188
+ addonId: null,
32189
+ access: "view"
32190
+ },
32191
+ "storageMigration.start": {
32192
+ capName: "storage-migration",
32193
+ capScope: "system",
32194
+ addonId: null,
32195
+ access: "create"
32196
+ },
32197
+ "storageMigration.status": {
32198
+ capName: "storage-migration",
32199
+ capScope: "system",
32200
+ addonId: null,
32201
+ access: "view"
32202
+ },
31845
32203
  "storageProvider.abortUpload": {
31846
32204
  capName: "storage-provider",
31847
32205
  capScope: "system",
@@ -32220,12 +32578,42 @@ Object.freeze({
32220
32578
  addonId: null,
32221
32579
  access: "create"
32222
32580
  },
32581
+ "terminalSession.adoptLegacyMonitor": {
32582
+ capName: "terminal-session",
32583
+ capScope: "system",
32584
+ addonId: null,
32585
+ access: "create"
32586
+ },
32223
32587
  "terminalSession.close": {
32224
32588
  capName: "terminal-session",
32225
32589
  capScope: "system",
32226
32590
  addonId: null,
32227
32591
  access: "create"
32228
32592
  },
32593
+ "terminalSession.createInstance": {
32594
+ capName: "terminal-session",
32595
+ capScope: "system",
32596
+ addonId: null,
32597
+ access: "create"
32598
+ },
32599
+ "terminalSession.deleteInstance": {
32600
+ capName: "terminal-session",
32601
+ capScope: "system",
32602
+ addonId: null,
32603
+ access: "delete"
32604
+ },
32605
+ "terminalSession.listInstances": {
32606
+ capName: "terminal-session",
32607
+ capScope: "system",
32608
+ addonId: null,
32609
+ access: "view"
32610
+ },
32611
+ "terminalSession.listLegacyCameras": {
32612
+ capName: "terminal-session",
32613
+ capScope: "system",
32614
+ addonId: null,
32615
+ access: "view"
32616
+ },
32229
32617
  "terminalSession.listProfiles": {
32230
32618
  capName: "terminal-session",
32231
32619
  capScope: "system",
@@ -32256,6 +32644,12 @@ Object.freeze({
32256
32644
  addonId: null,
32257
32645
  access: "create"
32258
32646
  },
32647
+ "terminalSession.setInstanceEnabled": {
32648
+ capName: "terminal-session",
32649
+ capScope: "system",
32650
+ addonId: null,
32651
+ access: "create"
32652
+ },
32259
32653
  "terminalSession.writeInput": {
32260
32654
  capName: "terminal-session",
32261
32655
  capScope: "system",
@@ -33058,26 +33452,56 @@ async function warmNodePty() {
33058
33452
  }
33059
33453
  //#endregion
33060
33454
  //#region src/terminal-camera-declarations.ts
33061
- function buildTerminalCameraDeclarations(nodes, profilesByNode) {
33062
- return nodes.flatMap((node) => {
33063
- const nodeLabel = node.isHub ? "Hub" : node.name;
33064
- return (profilesByNode.get(node.id) ?? []).map((profile) => ({
33065
- stableId: profile.profileId === "monitor" ? `terminal-camera-${node.id}` : `terminal-camera-${node.id}-${profile.profileId}`,
33066
- name: `Terminal ${profile.label} - ${nodeLabel}`,
33067
- config: {
33068
- nodeId: node.id,
33069
- profileId: profile.profileId,
33070
- profileLabel: profile.label
33071
- }
33072
- }));
33073
- });
33455
+ /**
33456
+ * Feed DeclaredDevices every live declaration plus one deterministic orphan
33457
+ * batch. The generic sweep intentionally refuses an over-limit set; selecting
33458
+ * a batch here drains large historical Terminal orphan sets across convergence
33459
+ * passes without weakening that global safety guard.
33460
+ */
33461
+ function terminalCameraReconciliationIndex(rows, integrationId, declarations, managedStableIds = /* @__PURE__ */ new Set()) {
33462
+ if (!integrationId) return [];
33463
+ const declared = new Set(declarations.map((camera) => camera.stableId));
33464
+ const owned = rows.filter((row) => row.integrationId === integrationId && (declared.has(row.stableId) || managedStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-")));
33465
+ 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)];
33466
+ }
33467
+ /** Explicit persisted instances, never the node × profile template matrix. */
33468
+ function buildTerminalInstanceCameraDeclarations(instances) {
33469
+ return instances.filter((instance) => instance.enabled).map((instance) => ({
33470
+ stableId: instance.cameraStableId,
33471
+ name: instance.name,
33472
+ config: {
33473
+ instanceId: instance.id,
33474
+ nodeId: instance.nodeId,
33475
+ profileId: instance.profileId,
33476
+ profileLabel: instance.profileLabel
33477
+ }
33478
+ }));
33479
+ }
33480
+ /**
33481
+ * `DeviceConfig` materializes schema defaults in memory, so comparing
33482
+ * `config.get()` cannot detect a legacy `{ nodeId }` row. Reconciliation must
33483
+ * inspect the raw persisted blob to make the profile migration durable.
33484
+ */
33485
+ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33486
+ return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33074
33487
  }
33075
33488
  function escapeXml(value) {
33076
33489
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33077
33490
  }
33078
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33491
+ /**
33492
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
33493
+ *
33494
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
33495
+ * runs of whitespace by default, and a terminal's entire column alignment IS
33496
+ * runs of whitespace — Glances pads every field with spaces. Without it the
33497
+ * frame drew each line at roughly half its true width, crammed into the
33498
+ * top-left of a mostly-black image, while the SAME session over `attach`
33499
+ * looked perfect — which is exactly how the operator reported it. Measured in
33500
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33501
+ * collapsed against 178 px preserved.
33502
+ */
33079
33503
  async function renderTerminalJpeg(lines) {
33080
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33504
+ const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33081
33505
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
33082
33506
  return sharp(Buffer.from(svg)).jpeg({
33083
33507
  quality: 82,
@@ -33087,22 +33511,35 @@ async function renderTerminalJpeg(lines) {
33087
33511
  //#endregion
33088
33512
  //#region src/terminal-camera-device.ts
33089
33513
  var terminalCameraSchema = object({
33514
+ instanceId: string().min(1).optional(),
33090
33515
  nodeId: string().min(1),
33091
- profileId: string().min(1),
33092
- profileLabel: string().min(1)
33516
+ profileId: string().min(1).default("monitor"),
33517
+ profileLabel: string().min(1).default("BTM")
33093
33518
  });
33094
33519
  var relay = null;
33095
33520
  function installTerminalCameraRelay(next) {
33096
33521
  relay = next;
33097
33522
  }
33098
33523
  var TerminalCameraDevice = class extends BaseDevice {
33099
- features = [];
33524
+ features = [DeviceFeature.NativeSnapshot];
33100
33525
  constructor(ctx) {
33101
33526
  super(ctx, terminalCameraSchema, { type: ctx.deviceMeta.type });
33102
33527
  this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
33103
33528
  if (deviceId !== this.id) return [];
33104
33529
  return this.catalog();
33105
33530
  } });
33531
+ this.ctx.registerNativeCap(snapshotCapability, {
33532
+ getSnapshot: async ({ deviceId }) => {
33533
+ if (deviceId !== this.id) throw new Error(`TerminalCameraDevice: deviceId mismatch, expected ${String(this.id)}, got ${String(deviceId)}`);
33534
+ const activeRelay = relay;
33535
+ if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33536
+ return {
33537
+ base64: (await activeRelay.snapshot(this.relayInstanceId(), this.config.get("nodeId"), this.config.get("profileId"))).toString("base64"),
33538
+ contentType: "image/jpeg"
33539
+ };
33540
+ },
33541
+ invalidateCache: async () => {}
33542
+ });
33106
33543
  this.markOnline(true);
33107
33544
  }
33108
33545
  async catalog() {
@@ -33110,10 +33547,11 @@ var TerminalCameraDevice = class extends BaseDevice {
33110
33547
  if (!activeRelay) throw new Error("terminal camera relay is unavailable");
33111
33548
  const nodeId = this.config.get("nodeId");
33112
33549
  const profileId = this.config.get("profileId");
33550
+ const instanceId = this.relayInstanceId();
33113
33551
  return [{
33114
33552
  camStreamId: profileId,
33115
33553
  kind: "pull-http",
33116
- url: activeRelay.streamUrl(nodeId, profileId),
33554
+ url: activeRelay.streamUrl(instanceId, nodeId, profileId),
33117
33555
  codec: "h264",
33118
33556
  resolution: {
33119
33557
  width: 960,
@@ -33125,6 +33563,13 @@ var TerminalCameraDevice = class extends BaseDevice {
33125
33563
  }
33126
33564
  setNodeOnline(online) {
33127
33565
  this.markOnline(online);
33566
+ if (!online) relay?.closeInstance(this.relayInstanceId());
33567
+ }
33568
+ async removeDevice() {
33569
+ await relay?.closeInstance(this.relayInstanceId());
33570
+ }
33571
+ relayInstanceId() {
33572
+ return this.config.get("instanceId") ?? `legacy:${this.stableId}`;
33128
33573
  }
33129
33574
  };
33130
33575
  //#endregion
@@ -37973,18 +38418,24 @@ function createXtermScreen(cols, rows) {
37973
38418
  //#region src/terminal-camera-relay.ts
37974
38419
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
37975
38420
  var SESSION_IDLE_MS = 3e4;
37976
- function relayKey(nodeId, profileId) {
37977
- return `${nodeId}\0${profileId}`;
38421
+ var SNAPSHOT_STARTUP_WAIT_MS = 1500;
38422
+ var CLOSE_RETRY_BASE_MS = 50;
38423
+ var CLOSE_RETRY_MAX_MS = 1e3;
38424
+ var CLOSE_ATTEMPTS_PER_PASS = 3;
38425
+ function relayKey(instanceId) {
38426
+ return instanceId;
37978
38427
  }
37979
38428
  function parseStreamPath(url) {
37980
38429
  const parts = ((url ?? "").split("?")[0] ?? "").split("/").filter(Boolean);
37981
- if (parts.length !== 3 || parts[0] !== "stream") return null;
38430
+ if (parts.length !== 4 || parts[0] !== "stream") return null;
37982
38431
  try {
37983
- const nodeId = decodeURIComponent(parts[1] ?? "");
37984
- const profilePart = parts[2] ?? "";
38432
+ const instanceId = decodeURIComponent(parts[1] ?? "");
38433
+ const nodeId = decodeURIComponent(parts[2] ?? "");
38434
+ const profilePart = parts[3] ?? "";
37985
38435
  if (!profilePart.endsWith(".mjpeg")) return null;
37986
38436
  const profileId = decodeURIComponent(profilePart.slice(0, -6));
37987
- return nodeId && profileId ? {
38437
+ return instanceId && nodeId && profileId ? {
38438
+ instanceId,
37988
38439
  nodeId,
37989
38440
  profileId
37990
38441
  } : null;
@@ -38011,7 +38462,7 @@ var TerminalCameraRelay = class {
38011
38462
  res.writeHead(404).end();
38012
38463
  return;
38013
38464
  }
38014
- this.serve(target.nodeId, target.profileId, res);
38465
+ this.serve(target.instanceId, target.nodeId, target.profileId, res);
38015
38466
  });
38016
38467
  await new Promise((resolve, reject) => {
38017
38468
  server.once("error", reject);
@@ -38025,55 +38476,105 @@ var TerminalCameraRelay = class {
38025
38476
  this.server = server;
38026
38477
  this.baseUrl = `http://127.0.0.1:${String(address.port)}`;
38027
38478
  }
38028
- streamUrl(nodeId, profileId) {
38479
+ streamUrl(instanceId, nodeId, profileId) {
38029
38480
  if (!this.baseUrl) throw new Error("terminal camera relay is not started");
38030
- return `${this.baseUrl}/stream/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38481
+ return `${this.baseUrl}/stream/${encodeURIComponent(instanceId)}/${encodeURIComponent(nodeId)}/${encodeURIComponent(profileId)}.mjpeg`;
38031
38482
  }
38032
38483
  async listProfiles(nodeId) {
38033
38484
  return this.api.listProfiles(nodeId);
38034
38485
  }
38035
- state(nodeId, profileId) {
38036
- const key = relayKey(nodeId, profileId);
38486
+ state(instanceId, nodeId, profileId) {
38487
+ const key = relayKey(instanceId);
38037
38488
  const existing = this.states.get(key);
38038
38489
  if (existing) return existing;
38039
38490
  const created = {
38491
+ instanceId,
38040
38492
  nodeId,
38041
38493
  profileId,
38042
38494
  screen: createXtermScreen(120, 40),
38043
38495
  sessionId: null,
38044
38496
  cursor: 0,
38045
38497
  clients: 0,
38498
+ leases: 0,
38046
38499
  jpeg: null,
38047
38500
  renderedCursor: -1,
38048
38501
  framePromise: null,
38049
- idleTimer: null
38502
+ openPromise: null,
38503
+ idleTimer: null,
38504
+ closing: false,
38505
+ closePromise: null,
38506
+ closeRetryTimer: null,
38507
+ closeAttempts: 0,
38508
+ closed: false,
38509
+ responses: /* @__PURE__ */ new Set()
38050
38510
  };
38051
38511
  this.states.set(key, created);
38052
38512
  return created;
38053
38513
  }
38054
38514
  async ensureSession(state) {
38515
+ if (state.closed || state.closing) throw new Error("terminal camera relay is waiting for prior session cleanup");
38055
38516
  if (state.sessionId) return state.sessionId;
38056
- const opened = await this.api.openSession(state.nodeId, {
38517
+ if (state.openPromise) {
38518
+ const opened = await state.openPromise;
38519
+ if (state.closed || state.closing) throw new Error("terminal camera relay state closed while opening its session");
38520
+ return opened.sessionId;
38521
+ }
38522
+ const opening = this.api.openSession(state.nodeId, {
38057
38523
  profileId: state.profileId,
38058
38524
  cols: 120,
38059
38525
  rows: 40
38060
38526
  });
38061
- state.sessionId = opened.sessionId;
38062
- state.cursor = 0;
38063
- return opened.sessionId;
38527
+ state.openPromise = opening;
38528
+ try {
38529
+ const opened = await opening;
38530
+ state.openPromise = null;
38531
+ if (state.closed || state.closing) {
38532
+ state.sessionId = opened.sessionId;
38533
+ await this.closeState(state);
38534
+ throw new Error("terminal camera relay state closed while opening its session");
38535
+ }
38536
+ state.sessionId = opened.sessionId;
38537
+ state.cursor = 0;
38538
+ return opened.sessionId;
38539
+ } catch (error) {
38540
+ if (state.closing && !state.sessionId) this.finishClose(state);
38541
+ throw error;
38542
+ } finally {
38543
+ if (state.openPromise === opening) state.openPromise = null;
38544
+ }
38064
38545
  }
38065
- async nextFrame(state) {
38066
- if (state.framePromise) return state.framePromise;
38546
+ async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38547
+ if (state.framePromise) {
38548
+ await state.framePromise;
38549
+ return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38550
+ }
38067
38551
  const render = async () => {
38552
+ const openingSession = state.sessionId === null;
38068
38553
  const sessionId = await this.ensureSession(state);
38069
38554
  let batch;
38070
38555
  try {
38071
38556
  batch = await this.api.pullOutput(state.nodeId, {
38072
38557
  sessionId,
38073
- afterSeq: state.cursor
38558
+ afterSeq: state.cursor,
38559
+ ...openingSession && waitForInitialOutput ? {
38560
+ waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38561
+ waitForOutput: true
38562
+ } : {}
38074
38563
  });
38075
38564
  } catch (error) {
38076
- state.sessionId = null;
38565
+ if (state.sessionId === sessionId) {
38566
+ let closed = false;
38567
+ await this.api.closeSession(state.nodeId, sessionId).then(() => {
38568
+ closed = true;
38569
+ }).catch((closeError) => {
38570
+ this.logger.warn("terminal camera session cleanup after output failure failed", { meta: {
38571
+ nodeId: state.nodeId,
38572
+ sessionId,
38573
+ error: closeError instanceof Error ? closeError.message : String(closeError)
38574
+ } });
38575
+ });
38576
+ if (closed) state.sessionId = null;
38577
+ }
38077
38578
  state.cursor = 0;
38078
38579
  throw error;
38079
38580
  }
@@ -38090,7 +38591,7 @@ var TerminalCameraRelay = class {
38090
38591
  }
38091
38592
  state.cursor = exited ? 0 : batch.cursor;
38092
38593
  await state.screen.flush();
38093
- if (state.jpeg === null || state.renderedCursor !== state.cursor) {
38594
+ if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38094
38595
  state.jpeg = await renderTerminalJpeg(state.screen.lines());
38095
38596
  state.renderedCursor = state.cursor;
38096
38597
  }
@@ -38101,14 +38602,15 @@ var TerminalCameraRelay = class {
38101
38602
  });
38102
38603
  return state.framePromise;
38103
38604
  }
38104
- async serve(nodeId, profileId, res) {
38605
+ async serve(instanceId, nodeId, profileId, res) {
38105
38606
  this.responses.add(res);
38106
- const state = this.state(nodeId, profileId);
38607
+ const state = this.state(instanceId, nodeId, profileId);
38107
38608
  if (state.idleTimer) clearTimeout(state.idleTimer);
38108
38609
  state.idleTimer = null;
38109
38610
  state.clients += 1;
38611
+ state.responses.add(res);
38110
38612
  try {
38111
- const first = await this.nextFrame(state);
38613
+ const first = await this.nextFrame(state, false, true);
38112
38614
  res.writeHead(200, {
38113
38615
  "content-type": `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
38114
38616
  "cache-control": "no-store",
@@ -38136,11 +38638,13 @@ var TerminalCameraRelay = class {
38136
38638
  res.end("terminal camera unavailable");
38137
38639
  } finally {
38138
38640
  this.responses.delete(res);
38641
+ state.responses.delete(res);
38139
38642
  state.clients = Math.max(0, state.clients - 1);
38140
- if (state.clients === 0) this.scheduleIdleClose(state);
38643
+ if (state.clients === 0 && state.leases === 0) this.scheduleIdleClose(state);
38141
38644
  }
38142
38645
  }
38143
38646
  scheduleIdleClose(state) {
38647
+ if (state.closed || state.closing || state.clients > 0 || state.leases > 0) return;
38144
38648
  if (state.idleTimer) clearTimeout(state.idleTimer);
38145
38649
  state.idleTimer = setTimeout(() => {
38146
38650
  this.closeState(state);
@@ -38148,26 +38652,116 @@ var TerminalCameraRelay = class {
38148
38652
  state.idleTimer.unref?.();
38149
38653
  }
38150
38654
  async closeState(state) {
38151
- if (state.clients > 0) return;
38152
- if (state.sessionId) await this.api.closeSession(state.nodeId, state.sessionId).catch((error) => {
38655
+ if (state.closed) return;
38656
+ if (state.clients > 0 || state.leases > 0) return;
38657
+ if (state.closePromise) {
38658
+ await state.closePromise;
38659
+ return;
38660
+ }
38661
+ if (state.openPromise && !state.sessionId) {
38662
+ state.closing = true;
38663
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38664
+ state.idleTimer = null;
38665
+ return;
38666
+ }
38667
+ if (state.closing && !state.sessionId) return;
38668
+ state.closing = true;
38669
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38670
+ state.idleTimer = null;
38671
+ state.closePromise = this.closeWithRetries(state).finally(() => {
38672
+ state.closePromise = null;
38673
+ });
38674
+ await state.closePromise;
38675
+ }
38676
+ async closeWithRetries(state) {
38677
+ const sessionId = state.sessionId;
38678
+ if (!sessionId) {
38679
+ this.finishClose(state);
38680
+ return;
38681
+ }
38682
+ for (let attempt = 0; attempt < CLOSE_ATTEMPTS_PER_PASS; attempt += 1) try {
38683
+ await this.api.closeSession(state.nodeId, sessionId);
38684
+ if (state.sessionId === sessionId) this.finishClose(state);
38685
+ return;
38686
+ } catch (error) {
38687
+ state.closeAttempts += 1;
38153
38688
  this.logger.warn("terminal camera session close failed", { meta: {
38154
38689
  nodeId: state.nodeId,
38155
- sessionId: state.sessionId,
38690
+ sessionId,
38691
+ attempt: state.closeAttempts,
38156
38692
  error: error instanceof Error ? error.message : String(error)
38157
38693
  } });
38158
- });
38694
+ if (attempt + 1 < CLOSE_ATTEMPTS_PER_PASS) await new Promise((resolve) => setTimeout(resolve, this.closeRetryDelay(state.closeAttempts)));
38695
+ }
38696
+ this.scheduleCloseRetry(state);
38697
+ }
38698
+ scheduleCloseRetry(state) {
38699
+ if (state.closeRetryTimer || !state.sessionId) return;
38700
+ state.closeRetryTimer = setTimeout(() => {
38701
+ state.closeRetryTimer = null;
38702
+ state.closing = false;
38703
+ this.closeState(state);
38704
+ }, this.closeRetryDelay(state.closeAttempts));
38705
+ state.closeRetryTimer.unref?.();
38706
+ }
38707
+ closeRetryDelay(attempt) {
38708
+ return Math.min(CLOSE_RETRY_BASE_MS * 2 ** Math.min(attempt - 1, 5), CLOSE_RETRY_MAX_MS);
38709
+ }
38710
+ finishClose(state) {
38711
+ if (state.closed) return;
38712
+ state.closed = true;
38713
+ if (state.closeRetryTimer) clearTimeout(state.closeRetryTimer);
38714
+ state.closeRetryTimer = null;
38715
+ state.sessionId = null;
38716
+ state.closeAttempts = 0;
38717
+ state.closing = false;
38718
+ if (this.states.get(relayKey(state.instanceId)) === state) this.states.delete(relayKey(state.instanceId));
38159
38719
  state.screen.dispose();
38160
- this.states.delete(relayKey(state.nodeId, state.profileId));
38720
+ }
38721
+ /**
38722
+ * Capture one fresh JPEG using the same xterm renderer as the MJPEG relay.
38723
+ * A snapshot-only caller owns a short lease and tears the state down as soon
38724
+ * as the image is rendered, so snapshots never leave a monitor PTY running.
38725
+ */
38726
+ async snapshot(instanceId, nodeId, profileId) {
38727
+ const state = this.state(instanceId, nodeId, profileId);
38728
+ if (state.idleTimer) clearTimeout(state.idleTimer);
38729
+ state.idleTimer = null;
38730
+ state.leases += 1;
38731
+ try {
38732
+ return await this.nextFrame(state, true, true);
38733
+ } finally {
38734
+ state.leases = Math.max(0, state.leases - 1);
38735
+ if (state.clients === 0 && state.leases === 0) await this.closeState(state);
38736
+ }
38737
+ }
38738
+ /** Stop a withdrawn/offline camera's relay, including active HTTP readers. */
38739
+ async closeInstance(instanceId) {
38740
+ const state = this.states.get(relayKey(instanceId));
38741
+ if (!state) return;
38742
+ for (const response of state.responses) response.destroy();
38743
+ state.responses.clear();
38744
+ state.clients = 0;
38745
+ state.leases = 0;
38746
+ await this.closeState(state);
38747
+ if (state.openPromise) {
38748
+ await state.openPromise.catch(() => {});
38749
+ await this.closeState(state);
38750
+ }
38161
38751
  }
38162
38752
  async dispose() {
38163
38753
  for (const response of this.responses) response.destroy();
38164
38754
  this.responses.clear();
38165
- for (const state of this.states.values()) {
38755
+ for (const state of [...this.states.values()]) {
38166
38756
  if (state.idleTimer) clearTimeout(state.idleTimer);
38167
38757
  state.clients = 0;
38758
+ state.leases = 0;
38168
38759
  await this.closeState(state);
38760
+ if (state.openPromise) {
38761
+ await state.openPromise.catch(() => {});
38762
+ await this.closeState(state);
38763
+ }
38169
38764
  }
38170
- this.states.clear();
38171
38765
  if (this.server) {
38172
38766
  const server = this.server;
38173
38767
  this.server = null;
@@ -38283,6 +38877,113 @@ function createTerminalDataPlaneHandler(manager) {
38283
38877
  };
38284
38878
  }
38285
38879
  //#endregion
38880
+ //#region src/terminal-instances.ts
38881
+ var TerminalInstanceSchema = object({
38882
+ id: string().uuid(),
38883
+ cameraStableId: string().min(1).max(256),
38884
+ nodeId: string().min(1).max(256),
38885
+ profileId: string().min(1).max(64),
38886
+ profileLabel: string().min(1).max(120),
38887
+ name: string().min(1).max(160),
38888
+ enabled: boolean()
38889
+ });
38890
+ /**
38891
+ * Config is operator-writable, so malformed or duplicate rows are ignored
38892
+ * rather than allowed to make declaration reconciliation destructive.
38893
+ */
38894
+ function readTerminalInstances(raw, onInvalid) {
38895
+ const ids = /* @__PURE__ */ new Set();
38896
+ const stableIds = /* @__PURE__ */ new Set();
38897
+ const instances = [];
38898
+ for (const value of raw) {
38899
+ const parsed = TerminalInstanceSchema.safeParse(value);
38900
+ if (!parsed.success) {
38901
+ onInvalid?.("Ignoring malformed Terminal instance configuration");
38902
+ continue;
38903
+ }
38904
+ const instance = parsed.data;
38905
+ if (ids.has(instance.id) || stableIds.has(instance.cameraStableId)) {
38906
+ onInvalid?.(`Ignoring duplicate Terminal instance ${instance.id}`);
38907
+ continue;
38908
+ }
38909
+ ids.add(instance.id);
38910
+ stableIds.add(instance.cameraStableId);
38911
+ instances.push(instance);
38912
+ }
38913
+ return instances;
38914
+ }
38915
+ function newTerminalCameraStableId(instanceId) {
38916
+ return `terminal-camera-instance-${instanceId}`;
38917
+ }
38918
+ /**
38919
+ * Legacy automatic cameras are migration candidates only. A tombstone is
38920
+ * durable deletion intent, so a lingering failed device removal must never
38921
+ * make that camera adoptable again.
38922
+ */
38923
+ function listLegacyTerminalCameraCandidates(rows, instanceStableIds, tombstones) {
38924
+ const legacy = [];
38925
+ for (const row of rows) {
38926
+ if (tombstones.has(row.stableId) || instanceStableIds.has(row.stableId) || row.stableId.startsWith("terminal-camera-instance-") || !row.stableId.startsWith("terminal-camera-")) continue;
38927
+ const nodeId = typeof row.config.nodeId === "string" ? row.config.nodeId : null;
38928
+ if (!nodeId) continue;
38929
+ const profileId = typeof row.config.profileId === "string" ? row.config.profileId : "monitor";
38930
+ const profileLabel = typeof row.config.profileLabel === "string" ? row.config.profileLabel : profileId === "monitor" ? "BTM" : profileId;
38931
+ legacy.push({
38932
+ stableId: row.stableId,
38933
+ nodeId,
38934
+ profileId,
38935
+ profileLabel,
38936
+ name: row.name,
38937
+ adoptable: profileId === "monitor" && row.stableId === `terminal-camera-${nodeId}`
38938
+ });
38939
+ }
38940
+ return legacy;
38941
+ }
38942
+ /** Serializes config read-modify-write operations and their reconciliation. */
38943
+ var TerminalInstanceMutationQueue = class {
38944
+ tail = Promise.resolve();
38945
+ async run(mutation) {
38946
+ const previous = this.tail;
38947
+ let release;
38948
+ this.tail = new Promise((resolve) => {
38949
+ release = resolve;
38950
+ });
38951
+ await previous;
38952
+ try {
38953
+ return await mutation();
38954
+ } finally {
38955
+ release?.();
38956
+ }
38957
+ }
38958
+ };
38959
+ /**
38960
+ * Coalesces periodic/config reconciliation requests onto the same serialized
38961
+ * lane as instance mutations. A pass never applies a declaration snapshot
38962
+ * concurrently with a create/delete/enable write.
38963
+ */
38964
+ var TerminalInstanceReconcileCoordinator = class {
38965
+ queue;
38966
+ dirty = false;
38967
+ running = null;
38968
+ constructor(queue) {
38969
+ this.queue = queue;
38970
+ }
38971
+ request(apply) {
38972
+ this.dirty = true;
38973
+ if (this.running) return this.running;
38974
+ const running = this.queue.run(async () => {
38975
+ while (this.dirty) {
38976
+ this.dirty = false;
38977
+ await apply();
38978
+ }
38979
+ });
38980
+ this.running = running.finally(() => {
38981
+ this.running = null;
38982
+ });
38983
+ return this.running;
38984
+ }
38985
+ };
38986
+ //#endregion
38286
38987
  //#region src/profiles.ts
38287
38988
  /**
38288
38989
  * The allowlist of programs an operator may open. The capability accepts a
@@ -38411,6 +39112,7 @@ var TerminalSessionManager = class {
38411
39112
  now;
38412
39113
  resolveBinary;
38413
39114
  maxSessions;
39115
+ instanceControl = null;
38414
39116
  constructor(opts) {
38415
39117
  this.opts = opts;
38416
39118
  this.profiles = buildProfiles({
@@ -38449,6 +39151,27 @@ var TerminalSessionManager = class {
38449
39151
  ...p.description !== void 0 ? { description: p.description } : {}
38450
39152
  }));
38451
39153
  }
39154
+ setInstanceControl(control) {
39155
+ this.instanceControl = control;
39156
+ }
39157
+ async listInstances() {
39158
+ return this.instanceControl?.listInstances() ?? [];
39159
+ }
39160
+ async createInstance(input) {
39161
+ return this.requireInstanceControl().createInstance(input);
39162
+ }
39163
+ async deleteInstance(input) {
39164
+ await this.requireInstanceControl().deleteInstance(input);
39165
+ }
39166
+ async setInstanceEnabled(input) {
39167
+ return this.requireInstanceControl().setInstanceEnabled(input);
39168
+ }
39169
+ async listLegacyCameras() {
39170
+ return this.instanceControl?.listLegacyCameras() ?? [];
39171
+ }
39172
+ async adoptLegacyMonitor(input) {
39173
+ return this.requireInstanceControl().adoptLegacyMonitor(input);
39174
+ }
38452
39175
  async listSessions() {
38453
39176
  return [...this.sessions.values()].filter((s) => !s.exited).map((s) => s.info);
38454
39177
  }
@@ -38496,10 +39219,12 @@ var TerminalSessionManager = class {
38496
39219
  outputWaiters: /* @__PURE__ */ new Set(),
38497
39220
  outputChars: 0,
38498
39221
  nextSeq: 1,
38499
- exited: false
39222
+ exited: false,
39223
+ disposed: false
38500
39224
  };
38501
39225
  this.sessions.set(sessionId, session);
38502
39226
  pty.onData((data) => {
39227
+ if (session.exited) return;
38503
39228
  session.screen.write(data);
38504
39229
  this.appendOutput(session, {
38505
39230
  kind: "data",
@@ -38513,27 +39238,7 @@ var TerminalSessionManager = class {
38513
39238
  } catch {}
38514
39239
  });
38515
39240
  pty.onExit((event) => {
38516
- session.exited = true;
38517
- session.lastExit = event;
38518
- this.appendOutput(session, {
38519
- kind: "exit",
38520
- exitCode: event.exitCode,
38521
- ...event.signal !== void 0 ? { signal: event.signal } : {}
38522
- });
38523
- for (const sink of session.sinks) try {
38524
- sink({
38525
- kind: "exit",
38526
- exitCode: event.exitCode,
38527
- signal: event.signal
38528
- });
38529
- } catch {}
38530
- session.sinks.clear();
38531
- const retire = setTimeout(() => {
38532
- session.screen.dispose();
38533
- this.sessions.delete(sessionId);
38534
- }, EXITED_RETENTION_MS);
38535
- retire.unref?.();
38536
- session.retireTimer = retire;
39241
+ this.finishSession(sessionId, session, event, true);
38537
39242
  });
38538
39243
  this.opts.logger.info("terminal: session opened", { meta: {
38539
39244
  sessionId,
@@ -38564,6 +39269,7 @@ var TerminalSessionManager = class {
38564
39269
  async close(input) {
38565
39270
  const session = this.sessions.get(input.sessionId);
38566
39271
  if (!session) return;
39272
+ this.finishSession(input.sessionId, session, { exitCode: 0 }, false);
38567
39273
  try {
38568
39274
  session.pty.kill();
38569
39275
  } catch {}
@@ -38572,7 +39278,7 @@ var TerminalSessionManager = class {
38572
39278
  async pullOutput(input) {
38573
39279
  const session = this.sessions.get(input.sessionId);
38574
39280
  if (!session) throw new Error(`No such terminal session: ${input.sessionId}`);
38575
- if (input.afterSeq > 0 && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
39281
+ if ((input.afterSeq > 0 || input.waitForOutput === true) && input.afterSeq === session.nextSeq - 1 && !session.exited && (input.waitMs ?? 0) > 0) await new Promise((resolve) => {
38576
39282
  const wake = () => {
38577
39283
  clearTimeout(timer);
38578
39284
  session.outputWaiters.delete(wake);
@@ -38583,6 +39289,11 @@ var TerminalSessionManager = class {
38583
39289
  session.outputWaiters.add(wake);
38584
39290
  });
38585
39291
  const cursor = session.nextSeq - 1;
39292
+ if (session.disposed) return {
39293
+ cursor,
39294
+ reset: false,
39295
+ events: session.output.filter((event) => event.seq > input.afterSeq)
39296
+ };
38586
39297
  const oldestSeq = session.output[0]?.seq ?? session.nextSeq;
38587
39298
  if (input.afterSeq === 0 || input.afterSeq < oldestSeq - 1 || input.afterSeq > cursor) {
38588
39299
  await session.screen.flush();
@@ -38663,14 +39374,51 @@ var TerminalSessionManager = class {
38663
39374
  }
38664
39375
  /** Kill every live session — called on addon shutdown. */
38665
39376
  disposeAll() {
38666
- for (const session of this.sessions.values()) {
38667
- if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39377
+ for (const [sessionId, session] of this.sessions) {
39378
+ this.finishSession(sessionId, session, { exitCode: 0 }, false);
38668
39379
  try {
38669
39380
  session.pty.kill();
38670
39381
  } catch {}
38671
- session.screen.dispose();
38672
39382
  }
38673
- this.sessions.clear();
39383
+ }
39384
+ finishSession(sessionId, session, exit, retainForLateExit) {
39385
+ if (session.exited) return;
39386
+ session.exited = true;
39387
+ session.lastExit = exit;
39388
+ this.appendOutput(session, {
39389
+ kind: "exit",
39390
+ exitCode: exit.exitCode,
39391
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39392
+ });
39393
+ for (const sink of session.sinks) try {
39394
+ sink({
39395
+ kind: "exit",
39396
+ exitCode: exit.exitCode,
39397
+ ...exit.signal !== void 0 ? { signal: exit.signal } : {}
39398
+ });
39399
+ } catch {}
39400
+ session.sinks.clear();
39401
+ if (!retainForLateExit) {
39402
+ this.disposeSession(session);
39403
+ this.sessions.delete(sessionId);
39404
+ return;
39405
+ }
39406
+ const retire = setTimeout(() => {
39407
+ this.disposeSession(session);
39408
+ this.sessions.delete(sessionId);
39409
+ }, EXITED_RETENTION_MS);
39410
+ retire.unref?.();
39411
+ session.retireTimer = retire;
39412
+ }
39413
+ disposeSession(session) {
39414
+ if (session.retireTimer !== void 0) clearTimeout(session.retireTimer);
39415
+ if (session.disposed) return;
39416
+ session.disposed = true;
39417
+ session.screen.dispose();
39418
+ }
39419
+ requireInstanceControl() {
39420
+ if (!this.instanceControl) throw new Error("Terminal instances are managed on the hub");
39421
+ return this.instanceControl;
38674
39422
  }
38675
39423
  };
38676
39424
  //#endregion
@@ -38688,7 +39436,9 @@ var DEFAULTS = {
38688
39436
  allowShell: false,
38689
39437
  shellPath: "",
38690
39438
  maxSessions: 4,
38691
- customProfiles: []
39439
+ customProfiles: [],
39440
+ terminalInstances: [],
39441
+ terminalCameraTombstones: []
38692
39442
  };
38693
39443
  var DATA_PLANE_PREFIX = "io";
38694
39444
  var CAMERA_RECONCILE_MS = 6e4;
@@ -38699,6 +39449,11 @@ var TerminalAddon = class extends BaseAddon {
38699
39449
  cameraRelay = null;
38700
39450
  cameraReconcileTimer = null;
38701
39451
  cameraProfilesByNode = /* @__PURE__ */ new Map();
39452
+ /** Prevent repeat writes when a legacy device stays live after migration. */
39453
+ migratedTerminalCameraConfigIds = /* @__PURE__ */ new Set();
39454
+ terminalCameraTombstones = /* @__PURE__ */ new Set();
39455
+ instanceMutationQueue = new TerminalInstanceMutationQueue();
39456
+ terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
38702
39457
  glancesPythonPath = "";
38703
39458
  constructor() {
38704
39459
  super({ ...DEFAULTS });
@@ -38727,18 +39482,22 @@ var TerminalAddon = class extends BaseAddon {
38727
39482
  customProfiles: this.config.customProfiles
38728
39483
  });
38729
39484
  this.manager = manager;
39485
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38730
39486
  if (declarationOwnerNodeId(this.ctx.kernel.localNodeId) === "hub") {
39487
+ const localNodeId = declarationOwnerNodeId(this.ctx.kernel.localNodeId);
38731
39488
  const cameraRelay = new TerminalCameraRelay({
38732
- listProfiles: (nodeId) => this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
38733
- openSession: (nodeId, input) => this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
38734
- pullOutput: (nodeId, input) => this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
39489
+ listProfiles: (nodeId) => nodeId === localNodeId ? manager.listProfiles() : this.ctx.api.terminalSession.listProfiles.query(void 0, nodePin(nodeId)),
39490
+ openSession: (nodeId, input) => nodeId === localNodeId ? manager.openSession(input) : this.ctx.api.terminalSession.openSession.mutate(input, nodePin(nodeId)),
39491
+ pullOutput: (nodeId, input) => nodeId === localNodeId ? manager.pullOutput(input) : this.ctx.api.terminalSession.pullOutput.mutate(input, nodePin(nodeId)),
38735
39492
  closeSession: async (nodeId, sessionId) => {
38736
- await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
39493
+ if (nodeId === localNodeId) await manager.close({ sessionId });
39494
+ else await this.ctx.api.terminalSession.close.mutate({ sessionId }, nodePin(nodeId));
38737
39495
  }
38738
39496
  }, this.ctx.logger.child("camera"));
38739
39497
  await cameraRelay.start();
38740
39498
  this.cameraRelay = cameraRelay;
38741
39499
  installTerminalCameraRelay(cameraRelay);
39500
+ manager.setInstanceControl(this.terminalInstanceControl());
38742
39501
  await this.reconcileTerminalCameras().catch((error) => {
38743
39502
  this.ctx.logger.warn("initial terminal camera reconciliation failed — will retry", { meta: { error: error instanceof Error ? error.message : String(error) } });
38744
39503
  });
@@ -38763,6 +39522,7 @@ var TerminalAddon = class extends BaseAddon {
38763
39522
  }];
38764
39523
  }
38765
39524
  async onConfigChanged() {
39525
+ this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
38766
39526
  this.manager?.reconfigureProfiles({
38767
39527
  btmPath: this.config.btmPath,
38768
39528
  btmEnabled: this.config.btmEnabled,
@@ -38779,6 +39539,9 @@ var TerminalAddon = class extends BaseAddon {
38779
39539
  maxSessions: this.config.maxSessions,
38780
39540
  customProfiles: this.config.customProfiles
38781
39541
  });
39542
+ if (this.cameraRelay) this.reconcileTerminalCameras().catch((error) => {
39543
+ this.ctx.logger.warn("terminal camera reconciliation after config change failed", { meta: { error: error instanceof Error ? error.message : String(error) } });
39544
+ });
38782
39545
  }
38783
39546
  async onShutdown() {
38784
39547
  if (this.cameraReconcileTimer) clearInterval(this.cameraReconcileTimer);
@@ -38794,55 +39557,46 @@ var TerminalAddon = class extends BaseAddon {
38794
39557
  this.manager = null;
38795
39558
  }
38796
39559
  async reconcileTerminalCameras() {
39560
+ return this.terminalReconcileCoordinator.request(() => this.applyTerminalCameraReconciliation());
39561
+ }
39562
+ async applyTerminalCameraReconciliation() {
38797
39563
  if (!this.cameraRelay) return;
39564
+ let terminalIntegrationId;
38798
39565
  const topology = await this.ctx.api.nodes.topology.query();
38799
39566
  if (topology.length === 0) throw new Error("cluster topology returned no nodes; refusing to withdraw declared cameras");
38800
39567
  const nodes = topology.filter((node) => typeof node.id === "string" && node.id.length > 0);
39568
+ const nodeIds = new Set(nodes.map((node) => node.id));
39569
+ for (const cachedNodeId of this.cameraProfilesByNode.keys()) if (!nodeIds.has(cachedNodeId)) this.cameraProfilesByNode.delete(cachedNodeId);
38801
39570
  const unavailableNodeIds = /* @__PURE__ */ new Set();
38802
39571
  await Promise.all(nodes.map(async (node) => {
38803
39572
  try {
38804
39573
  this.cameraProfilesByNode.set(node.id, await this.cameraRelay.listProfiles(node.id));
38805
39574
  } catch (error) {
38806
- if (!this.cameraProfilesByNode.has(node.id)) {
38807
- unavailableNodeIds.add(node.id);
38808
- this.ctx.logger.warn("terminal profiles unavailable — preserving existing cameras for this node", { meta: {
38809
- nodeId: node.id,
38810
- error: error instanceof Error ? error.message : String(error)
38811
- } });
38812
- }
39575
+ unavailableNodeIds.add(node.id);
39576
+ this.ctx.logger.warn("terminal profiles unavailable — keeping Terminal instance cameras offline", { meta: {
39577
+ nodeId: node.id,
39578
+ cachedProfiles: this.cameraProfilesByNode.has(node.id),
39579
+ error: error instanceof Error ? error.message : String(error)
39580
+ } });
38813
39581
  }
38814
39582
  }));
38815
- const cameraDeclarations = [...buildTerminalCameraDeclarations(nodes, this.cameraProfilesByNode)];
38816
- if (unavailableNodeIds.size > 0) {
38817
- const existing = await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id });
38818
- const unavailableNodes = nodes.filter((node) => unavailableNodeIds.has(node.id)).sort((left, right) => right.id.length - left.id.length);
38819
- for (const row of existing) {
38820
- const node = unavailableNodes.find((candidate) => {
38821
- const base = `terminal-camera-${candidate.id}`;
38822
- return row.stableId === base || row.stableId.startsWith(`${base}-`);
38823
- });
38824
- if (!node || cameraDeclarations.some((camera) => camera.stableId === row.stableId)) continue;
38825
- const base = `terminal-camera-${node.id}`;
38826
- const profileId = row.stableId === base ? "monitor" : row.stableId.slice(base.length + 1);
38827
- const profileLabel = profileId === "monitor" ? "BTM" : profileId;
38828
- cameraDeclarations.push({
38829
- stableId: row.stableId,
38830
- name: `Terminal ${profileLabel} - ${node.isHub ? "Hub" : node.name}`,
38831
- config: {
38832
- nodeId: node.id,
38833
- profileId,
38834
- profileLabel
38835
- }
38836
- });
38837
- }
38838
- }
39583
+ const cameraDeclarations = buildTerminalInstanceCameraDeclarations(this.terminalInstances());
39584
+ const declarationsByStableId = new Map(cameraDeclarations.map((camera) => [camera.stableId, camera]));
38839
39585
  const result = await new DeclaredDevices({
38840
39586
  logger: this.ctx.logger.child("camera-declaration"),
38841
39587
  addonId: this.ctx.id,
38842
39588
  devices: this.ctx.kernel.devices,
38843
39589
  localNodeId: this.ctx.kernel.localNodeId,
38844
- getIntegration: async (addonId) => this.ctx.api.integrations.getByAddonId.query({ addonId }),
38845
- createIntegration: async (input) => this.ctx.api.integrations.create.mutate(input),
39590
+ getIntegration: async (addonId) => {
39591
+ const integration = await this.ctx.api.integrations.getByAddonId.query({ addonId });
39592
+ terminalIntegrationId = integration?.id ?? null;
39593
+ return integration;
39594
+ },
39595
+ createIntegration: async (input) => {
39596
+ const integration = await this.ctx.api.integrations.create.mutate(input);
39597
+ terminalIntegrationId = integration.id;
39598
+ return integration;
39599
+ },
38846
39600
  updateIntegration: async ({ id, info }) => {
38847
39601
  await this.ctx.api.integrations.update.mutate({
38848
39602
  id,
@@ -38850,7 +39604,9 @@ var TerminalAddon = class extends BaseAddon {
38850
39604
  skipRestart: true
38851
39605
  });
38852
39606
  },
38853
- listOwnDevices: async () => this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id })
39607
+ listOwnDevices: async () => {
39608
+ return terminalCameraReconciliationIndex(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), terminalIntegrationId, cameraDeclarations, this.terminalCameraTombstones);
39609
+ }
38854
39610
  }).reconcile({
38855
39611
  integrationName: TERMINAL_CAMERA_INTEGRATION,
38856
39612
  placement: "hub",
@@ -38863,12 +39619,176 @@ var TerminalAddon = class extends BaseAddon {
38863
39619
  role: "terminal-camera"
38864
39620
  }))
38865
39621
  });
38866
- const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline]));
39622
+ const onlineByNode = new Map(nodes.map((node) => [node.id, node.isOnline && !unavailableNodeIds.has(node.id)]));
38867
39623
  for (const outcome of result.devices) if (outcome.device instanceof TerminalCameraDevice) {
39624
+ const declaration = declarationsByStableId.get(outcome.stableId);
39625
+ if (declaration) {
39626
+ const config = outcome.device.config;
39627
+ 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)) {
39628
+ await config.setAll(declaration.config);
39629
+ this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39630
+ }
39631
+ }
39632
+ if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
38868
39633
  const nodeId = outcome.device.config.get("nodeId");
38869
- outcome.device.setNodeOnline(onlineByNode.get(nodeId) ?? false);
39634
+ const profileId = outcome.device.config.get("profileId");
39635
+ const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39636
+ outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
39637
+ }
39638
+ }
39639
+ /**
39640
+ * A Terminal camera is a rendered screen. Object detection on it finds
39641
+ * nothing, forever, at full cost.
39642
+ *
39643
+ * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
39644
+ * through the detection pipeline at ~61 ms of inference each, plus 115
39645
+ * capture-scheduler requests a minute — against `detections=0`. Multiply by
39646
+ * one terminal per node and it is a standing tax on a hub that was already
39647
+ * shedding 86 % of its capture queue.
39648
+ *
39649
+ * Written through `setCameraSwitch`, which is the authority that already owns
39650
+ * this function — [D62] forbids a second store that disagrees with it. And
39651
+ * written ONLY on creation: an operator who deliberately turns detection back
39652
+ * on for a terminal must win, and a reconcile that re-asserted every pass
39653
+ * would silently overrule them once a minute.
39654
+ */
39655
+ async silenceAnalysisFor(deviceId) {
39656
+ for (const switchId of ["object-detection", "audio-analysis"]) try {
39657
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
39658
+ deviceId,
39659
+ switchId,
39660
+ enabled: false
39661
+ });
39662
+ } catch (err) {
39663
+ this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
39664
+ tags: { deviceId },
39665
+ meta: {
39666
+ switchId,
39667
+ error: err instanceof Error ? err.message : String(err)
39668
+ }
39669
+ });
38870
39670
  }
38871
39671
  }
39672
+ terminalInstanceControl() {
39673
+ return {
39674
+ listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
39675
+ createInstance: async (input) => this.createTerminalInstance(input),
39676
+ deleteInstance: async ({ instanceId }) => this.deleteTerminalInstance(instanceId),
39677
+ setInstanceEnabled: async ({ instanceId, enabled }) => this.setTerminalInstanceEnabled(instanceId, enabled),
39678
+ listLegacyCameras: async () => this.listLegacyTerminalCameras(),
39679
+ adoptLegacyMonitor: async ({ stableId, name }) => this.adoptLegacyMonitor(stableId, name)
39680
+ };
39681
+ }
39682
+ terminalInstances() {
39683
+ return readTerminalInstances(this.config.terminalInstances, (message) => {
39684
+ this.ctx.logger.warn(message);
39685
+ });
39686
+ }
39687
+ instanceInfo(instance) {
39688
+ return {
39689
+ instanceId: instance.id,
39690
+ cameraStableId: instance.cameraStableId,
39691
+ nodeId: instance.nodeId,
39692
+ profileId: instance.profileId,
39693
+ profileLabel: instance.profileLabel,
39694
+ name: instance.name,
39695
+ enabled: instance.enabled
39696
+ };
39697
+ }
39698
+ replaceTerminalCameraTombstones(stableIds) {
39699
+ this.terminalCameraTombstones.clear();
39700
+ for (const stableId of stableIds) if (typeof stableId === "string" && stableId.length > 0) this.terminalCameraTombstones.add(stableId);
39701
+ }
39702
+ async createTerminalInstance(input) {
39703
+ const instance = await this.instanceMutationQueue.run(() => this.createTerminalInstanceUnlocked(input));
39704
+ await this.reconcileTerminalCameras();
39705
+ return instance;
39706
+ }
39707
+ async createTerminalInstanceUnlocked(input) {
39708
+ const relay = this.cameraRelay;
39709
+ if (!relay) throw new Error("Terminal instances are managed on the hub");
39710
+ const profile = (await relay.listProfiles(input.targetNodeId)).find((candidate) => candidate.profileId === input.profileId);
39711
+ if (!profile) throw new Error(`Terminal profile '${input.profileId}' is not available on ${input.targetNodeId}`);
39712
+ const node = (await this.ctx.api.nodes.topology.query()).find((candidate) => candidate.id === input.targetNodeId);
39713
+ if (!node) throw new Error(`Terminal node '${input.targetNodeId}' no longer exists`);
39714
+ const id = crypto.randomUUID();
39715
+ const name = input.name?.trim() || `Terminal ${profile.label} - ${node.isHub ? "Hub" : node.name}`;
39716
+ const instance = {
39717
+ id,
39718
+ cameraStableId: newTerminalCameraStableId(id),
39719
+ nodeId: input.targetNodeId,
39720
+ profileId: profile.profileId,
39721
+ profileLabel: profile.label,
39722
+ name,
39723
+ enabled: true
39724
+ };
39725
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39726
+ return this.instanceInfo(instance);
39727
+ }
39728
+ async deleteTerminalInstance(instanceId) {
39729
+ await this.instanceMutationQueue.run(() => this.deleteTerminalInstanceUnlocked(instanceId));
39730
+ await this.reconcileTerminalCameras();
39731
+ }
39732
+ async deleteTerminalInstanceUnlocked(instanceId) {
39733
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39734
+ if (!instance) return;
39735
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39736
+ await this.cameraRelay?.closeInstance(instance.id);
39737
+ await this.updateGlobalSettings({
39738
+ terminalInstances: this.config.terminalInstances.filter((candidate) => candidate.id !== instanceId),
39739
+ terminalCameraTombstones: [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])]
39740
+ });
39741
+ }
39742
+ async setTerminalInstanceEnabled(instanceId, enabled) {
39743
+ const instance = await this.instanceMutationQueue.run(() => this.setTerminalInstanceEnabledUnlocked(instanceId, enabled));
39744
+ await this.reconcileTerminalCameras();
39745
+ return instance;
39746
+ }
39747
+ async setTerminalInstanceEnabledUnlocked(instanceId, enabled) {
39748
+ const instance = this.terminalInstances().find((candidate) => candidate.id === instanceId);
39749
+ if (!instance) throw new Error(`No such Terminal instance: ${instanceId}`);
39750
+ if (!enabled) {
39751
+ this.terminalCameraTombstones.add(instance.cameraStableId);
39752
+ await this.cameraRelay?.closeInstance(instance.id);
39753
+ }
39754
+ const updated = {
39755
+ ...instance,
39756
+ enabled
39757
+ };
39758
+ const terminalInstances = this.config.terminalInstances.map((candidate) => candidate.id === instanceId ? updated : candidate);
39759
+ const terminalCameraTombstones = enabled ? this.config.terminalCameraTombstones.filter((stableId) => stableId !== instance.cameraStableId) : [...new Set([...this.config.terminalCameraTombstones, instance.cameraStableId])];
39760
+ await this.updateGlobalSettings({
39761
+ terminalInstances,
39762
+ terminalCameraTombstones
39763
+ });
39764
+ return this.instanceInfo(updated);
39765
+ }
39766
+ async listLegacyTerminalCameras() {
39767
+ const instances = this.terminalInstances();
39768
+ const instanceStableIds = new Set(instances.map((instance) => instance.cameraStableId));
39769
+ return listLegacyTerminalCameraCandidates(await this.ctx.api.deviceManager.listAll.query({ addonId: this.ctx.id }), instanceStableIds, this.terminalCameraTombstones);
39770
+ }
39771
+ async adoptLegacyMonitor(stableId, requestedName) {
39772
+ const instance = await this.instanceMutationQueue.run(() => this.adoptLegacyMonitorUnlocked(stableId, requestedName));
39773
+ await this.reconcileTerminalCameras();
39774
+ return instance;
39775
+ }
39776
+ async adoptLegacyMonitorUnlocked(stableId, requestedName) {
39777
+ if (this.terminalCameraTombstones.has(stableId)) throw new Error("This legacy Terminal camera was deleted and cannot be adopted");
39778
+ const legacy = (await this.listLegacyTerminalCameras()).find((camera) => camera.stableId === stableId);
39779
+ if (!legacy?.adoptable) throw new Error("Only a legacy monitor camera with its original stable id can be adopted");
39780
+ const instance = {
39781
+ id: crypto.randomUUID(),
39782
+ cameraStableId: legacy.stableId,
39783
+ nodeId: legacy.nodeId,
39784
+ profileId: "monitor",
39785
+ profileLabel: legacy.profileLabel,
39786
+ name: requestedName?.trim() || legacy.name,
39787
+ enabled: true
39788
+ };
39789
+ await this.updateGlobalSettings({ terminalInstances: [...this.config.terminalInstances, instance] });
39790
+ return this.instanceInfo(instance);
39791
+ }
38872
39792
  globalSettingsSchema() {
38873
39793
  return this.schema({ sections: [{
38874
39794
  id: "terminal",
@@ -38979,7 +39899,7 @@ var TerminalAddon = class extends BaseAddon {
38979
39899
  }, {
38980
39900
  id: "terminal-profiles",
38981
39901
  title: "Custom profiles",
38982
- 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.",
39902
+ 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.",
38983
39903
  columns: 1,
38984
39904
  fields: [this.field({
38985
39905
  type: "editable-array",