@camstack/addon-terminal 0.1.13 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import { accessSync, chmodSync, constants, existsSync, statSync } from "node:fs";
|
|
3
|
-
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import path, { delimiter, dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
4
6
|
import sharp from "sharp";
|
|
5
7
|
import { createServer } from "node:http";
|
|
6
8
|
//#region \0rolldown/runtime.js
|
|
@@ -7022,6 +7024,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7022
7024
|
action: string().min(1),
|
|
7023
7025
|
input: unknown()
|
|
7024
7026
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7027
|
+
//#endregion
|
|
7028
|
+
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7029
|
+
/**
|
|
7030
|
+
import { errMsg } from '@camstack/types'
|
|
7031
|
+
* Extract a human-readable message from an unknown error value.
|
|
7032
|
+
* Replaces the ubiquitous `errMsg(err)` pattern.
|
|
7033
|
+
*/
|
|
7034
|
+
function errMsg(err) {
|
|
7035
|
+
if (err instanceof Error) return err.message;
|
|
7036
|
+
if (typeof err === "string") return err;
|
|
7037
|
+
return String(err);
|
|
7038
|
+
}
|
|
7025
7039
|
var EncodeProfileSchema = object({
|
|
7026
7040
|
video: object({
|
|
7027
7041
|
codec: _enum([
|
|
@@ -7259,8 +7273,31 @@ var AdoptionJobSchema = object({
|
|
|
7259
7273
|
error: string().nullable()
|
|
7260
7274
|
});
|
|
7261
7275
|
/**
|
|
7262
|
-
* Per-camera FUNCTION SWITCHES
|
|
7263
|
-
*
|
|
7276
|
+
* Per-camera FUNCTION SWITCHES.
|
|
7277
|
+
*
|
|
7278
|
+
* ## The aggregate group is being withdrawn — the BADGE is not (D113)
|
|
7279
|
+
*
|
|
7280
|
+
* This file shipped as "the one coherent on/off surface over the pipeline
|
|
7281
|
+
* functions an operator thinks in terms of". The operator's verdict on
|
|
7282
|
+
* 2026-08-12 was that the coherent surface bought complexity and no clarity:
|
|
7283
|
+
* every function already had a settings page of its own, and a second place to
|
|
7284
|
+
* turn it off is a second place to look. Each switch is going back to its own
|
|
7285
|
+
* component's original options — detection to the detection-pipeline wrapper
|
|
7286
|
+
* binding, audio analysis to its own, recording to `RecordingConfig.enabled`
|
|
7287
|
+
* (which was always first-class; the switch was a veneer over
|
|
7288
|
+
* `recording.setDeviceConfig`), notifications to a notification-center
|
|
7289
|
+
* per-device setting, the two camera planes to their own components.
|
|
7290
|
+
*
|
|
7291
|
+
* What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
|
|
7292
|
+
* thing that lets a status surface say DISABLED instead of BROKEN, recomposed
|
|
7293
|
+
* straight from the authorities with no group in the middle. That rule was
|
|
7294
|
+
* never about a control panel.
|
|
7295
|
+
*
|
|
7296
|
+
* Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
|
|
7297
|
+
* {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
|
|
7298
|
+
* `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
|
|
7299
|
+
* viewers (v1.0.305) and the admin UI still call it. It is deleted when they
|
|
7300
|
+
* stop; nothing new may be built on it.
|
|
7264
7301
|
*
|
|
7265
7302
|
* ## This file adds no state
|
|
7266
7303
|
*
|
|
@@ -7422,6 +7459,14 @@ var CameraSwitchGroupSchema = object({
|
|
|
7422
7459
|
fetchedAt: number()
|
|
7423
7460
|
});
|
|
7424
7461
|
/**
|
|
7462
|
+
* The wrapper capability each wrapper-backed switch controls. Named constants
|
|
7463
|
+
* because the same strings appear in `legacy-migrations.ts`, in
|
|
7464
|
+
* `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
|
|
7465
|
+
* them is a switch that silently writes a binding nobody reads.
|
|
7466
|
+
*/
|
|
7467
|
+
var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
|
|
7468
|
+
var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
|
|
7469
|
+
/**
|
|
7425
7470
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7426
7471
|
* recordings and events management surfaces.
|
|
7427
7472
|
*
|
|
@@ -17797,9 +17842,16 @@ var CameraStatusSchema = object({
|
|
|
17797
17842
|
audio: CameraAudioStatusSchema.nullable(),
|
|
17798
17843
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
17799
17844
|
/**
|
|
17800
|
-
* Per-camera
|
|
17845
|
+
* Per-camera functions an OPERATOR has turned off
|
|
17801
17846
|
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
17802
17847
|
*
|
|
17848
|
+
* Composed from the AUTHORITIES themselves — the wrapper bindings,
|
|
17849
|
+
* `RecordingConfig.enabled`, the notification mute, the broker's audio
|
|
17850
|
+
* policy, the camera's own microphone — via `composeSwitchedOff`, not from
|
|
17851
|
+
* the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
|
|
17852
|
+
* The badge outlives the control panel: the panel was a convenience, this is
|
|
17853
|
+
* the difference between a camera being off and a camera being dead.
|
|
17854
|
+
*
|
|
17803
17855
|
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
17804
17856
|
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
17805
17857
|
* `'object-detection'` was switched off by a person; the same camera with an
|
|
@@ -24886,10 +24938,42 @@ method(object({
|
|
|
24886
24938
|
*/
|
|
24887
24939
|
/** Playback-speed multiplier for the render (1 = realtime). */
|
|
24888
24940
|
var ExportSpeedSchema = number().min(.25).max(32);
|
|
24941
|
+
/**
|
|
24942
|
+
* One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
|
|
24943
|
+
*
|
|
24944
|
+
* Relative and not absolute epoch on purpose: the renderer's frame-select
|
|
24945
|
+
* expression sees ffmpeg's `t`, which starts at 0 for the export's source
|
|
24946
|
+
* playlist. Handing it absolute epochs would make every call site responsible
|
|
24947
|
+
* for the same subtraction, and the one that forgot would emit a filter that
|
|
24948
|
+
* selects nothing — silently, as a uniform timelapse.
|
|
24949
|
+
*/
|
|
24950
|
+
var ExportDenseRangeSchema = object({
|
|
24951
|
+
fromSec: number().nonnegative(),
|
|
24952
|
+
toSec: number().nonnegative()
|
|
24953
|
+
}).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
|
|
24954
|
+
/**
|
|
24955
|
+
* Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
|
|
24956
|
+
* listed ranges and at the base `everyMs` everywhere else.
|
|
24957
|
+
*
|
|
24958
|
+
* `everyMs` must be strictly smaller than the base cadence — a dense rate that
|
|
24959
|
+
* is not denser renders a uniform timelapse the operator believes is two-rate.
|
|
24960
|
+
*/
|
|
24961
|
+
var ExportDenseSchema = object({
|
|
24962
|
+
everyMs: number().int().positive(),
|
|
24963
|
+
ranges: array(ExportDenseRangeSchema).min(1).max(200)
|
|
24964
|
+
});
|
|
24889
24965
|
/** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
|
|
24890
24966
|
var ExportTimelapseSchema = object({
|
|
24891
24967
|
everyMs: number().int().positive(),
|
|
24892
|
-
outputFps: number().int().min(1).max(60).optional()
|
|
24968
|
+
outputFps: number().int().min(1).max(60).optional(),
|
|
24969
|
+
/** Optional second, FASTER rate over the intervals that matter. */
|
|
24970
|
+
dense: ExportDenseSchema.optional()
|
|
24971
|
+
}).superRefine((v, ctx) => {
|
|
24972
|
+
if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
|
|
24973
|
+
code: ZodIssueCode.custom,
|
|
24974
|
+
message: "dense.everyMs must be strictly smaller than the base everyMs",
|
|
24975
|
+
path: ["dense", "everyMs"]
|
|
24976
|
+
});
|
|
24893
24977
|
});
|
|
24894
24978
|
/**
|
|
24895
24979
|
* Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
|
|
@@ -24947,6 +25031,19 @@ var ExportDownloadSchema = object({
|
|
|
24947
25031
|
url: string(),
|
|
24948
25032
|
endpoints: array(string())
|
|
24949
25033
|
});
|
|
25034
|
+
/**
|
|
25035
|
+
* A finished export's bytes, inline.
|
|
25036
|
+
*
|
|
25037
|
+
* `bytes` is the DECODED length — the number the caller bounds and logs
|
|
25038
|
+
* against, so nobody has to infer it from the base64 length.
|
|
25039
|
+
*/
|
|
25040
|
+
var ExportBytesSchema = object({
|
|
25041
|
+
base64: string(),
|
|
25042
|
+
contentType: string(),
|
|
25043
|
+
/** Suggested filename, extension included. */
|
|
25044
|
+
name: string(),
|
|
25045
|
+
bytes: number().int().nonnegative()
|
|
25046
|
+
});
|
|
24950
25047
|
method(object({
|
|
24951
25048
|
deviceId: number(),
|
|
24952
25049
|
profile: string(),
|
|
@@ -24971,6 +25068,9 @@ method(object({
|
|
|
24971
25068
|
}), method(object({ exportId: string() }), ExportDownloadSchema, {
|
|
24972
25069
|
kind: "query",
|
|
24973
25070
|
auth: "protected"
|
|
25071
|
+
}), method(object({ exportId: string() }), ExportBytesSchema, {
|
|
25072
|
+
kind: "query",
|
|
25073
|
+
auth: "protected"
|
|
24974
25074
|
});
|
|
24975
25075
|
/**
|
|
24976
25076
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
@@ -31822,6 +31922,12 @@ Object.freeze({
|
|
|
31822
31922
|
addonId: null,
|
|
31823
31923
|
access: "view"
|
|
31824
31924
|
},
|
|
31925
|
+
"recordingExport.readExportBytes": {
|
|
31926
|
+
capName: "recordingExport",
|
|
31927
|
+
capScope: "system",
|
|
31928
|
+
addonId: null,
|
|
31929
|
+
access: "view"
|
|
31930
|
+
},
|
|
31825
31931
|
"sceneMonitor.captureReference": {
|
|
31826
31932
|
capName: "scene-monitor",
|
|
31827
31933
|
capScope: "device",
|
|
@@ -33239,10 +33345,28 @@ TimelapseRuleInputSchema.extend({
|
|
|
33239
33345
|
*/
|
|
33240
33346
|
ownerUserId: string().optional(),
|
|
33241
33347
|
/**
|
|
33242
|
-
* Epoch-ms of the
|
|
33243
|
-
*
|
|
33348
|
+
* Epoch-ms of the NEWEST successful generation across every camera of this
|
|
33349
|
+
* rule. What a UI shows, and the compatibility floor for
|
|
33350
|
+
* {@link readTimelapseGeneratedAt}. Absent = never generated.
|
|
33244
33351
|
*/
|
|
33245
33352
|
lastGeneratedAt: number().optional(),
|
|
33353
|
+
/**
|
|
33354
|
+
* PER-CAMERA generation state, keyed by `String(deviceId)` — the
|
|
33355
|
+
* re-generation guard's real durable state.
|
|
33356
|
+
*
|
|
33357
|
+
* One rule covers several cameras and each renders its own video, so a rule
|
|
33358
|
+
* -wide stamp is wrong in the direction that DESTROYS work: camera A
|
|
33359
|
+
* succeeding at 06:05 tells camera B, whose render failed, that it is
|
|
33360
|
+
* already done — and B's night is gone for good, because the window will not
|
|
33361
|
+
* come back.
|
|
33362
|
+
*
|
|
33363
|
+
* ADDITIVE, so the migration is free: a row written before this field simply
|
|
33364
|
+
* has no map, and {@link readTimelapseGeneratedAt} falls back to
|
|
33365
|
+
* {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
|
|
33366
|
+
* "never generated" would re-render and re-notify every camera of every rule
|
|
33367
|
+
* once, on the deploy that shipped the map.
|
|
33368
|
+
*/
|
|
33369
|
+
generatedByDevice: record(string(), number()).optional(),
|
|
33246
33370
|
/** userId of the caller who created the rule (server-stamped). */
|
|
33247
33371
|
createdBy: string(),
|
|
33248
33372
|
createdAt: number(),
|
|
@@ -33355,6 +33479,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
|
33355
33479
|
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
33356
33480
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
33357
33481
|
//#endregion
|
|
33482
|
+
//#region src/curses-shim.ts
|
|
33483
|
+
/**
|
|
33484
|
+
* macOS repair for the managed Python's curses.
|
|
33485
|
+
*
|
|
33486
|
+
* The python-build-standalone distribution the dependency resolver installs on
|
|
33487
|
+
* macOS links ncurses statically with the terminfo database compiled OUT
|
|
33488
|
+
* (`--disable-database`) and an ABI mismatch that misparses every numeric
|
|
33489
|
+
* capability of its five built-in fallback terminals (`colors=-1`,
|
|
33490
|
+
* `cols=524368`). `has_colors()` is therefore False under every TERM value and
|
|
33491
|
+
* `init_pair` is hard-blocked, so glances takes its B&W branch and paints the
|
|
33492
|
+
* whole screen with SGR conceal/reverse/alt-charset — the "text missing,
|
|
33493
|
+
* light bars instead" terminal camera frames. No environment variable can fix
|
|
33494
|
+
* it: the code that would read TERMINFO/TERMINFO_DIRS was never compiled in.
|
|
33495
|
+
*
|
|
33496
|
+
* The repair ships prebuilt `_curses` / `_curses_panel` extension modules
|
|
33497
|
+
* linked against the system `/usr/lib/libncurses.5.4.dylib` (universal2) and
|
|
33498
|
+
* puts their directory on PYTHONPATH at PTY spawn — PYTHONPATH precedes
|
|
33499
|
+
* lib-dynload on sys.path, so the working modules shadow the broken ones with
|
|
33500
|
+
* no change to the managed distribution. The filename is ABI-locked to
|
|
33501
|
+
* cpython-312: a managed-python bump to 3.13 makes the shim silently inert
|
|
33502
|
+
* (python ignores a mismatched ABI tag), which is why the addon probes
|
|
33503
|
+
* `tigetnum("colors")` at startup and logs loudly when colours are missing.
|
|
33504
|
+
*/
|
|
33505
|
+
/** `<addonRoot>/assets/curses-shim/py312`, valid from both src/ and dist/. */
|
|
33506
|
+
function cursesShimDir() {
|
|
33507
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "curses-shim", "py312");
|
|
33508
|
+
}
|
|
33509
|
+
function cursesShimDirExists(shimDir) {
|
|
33510
|
+
return existsSync(shimDir);
|
|
33511
|
+
}
|
|
33512
|
+
/**
|
|
33513
|
+
* The env to merge into the glances profile, or undefined when the shim does
|
|
33514
|
+
* not apply (non-macOS, or the assets were not shipped).
|
|
33515
|
+
*/
|
|
33516
|
+
function resolveGlancesCursesShimEnv(options) {
|
|
33517
|
+
if (options.platform !== "darwin" || !options.shimDirExists) return void 0;
|
|
33518
|
+
const existing = options.existingPythonPath?.trim();
|
|
33519
|
+
return { PYTHONPATH: existing ? `${options.shimDir}:${existing}` : options.shimDir };
|
|
33520
|
+
}
|
|
33521
|
+
//#endregion
|
|
33358
33522
|
//#region src/pty.ts
|
|
33359
33523
|
/**
|
|
33360
33524
|
* Minimal pty abstraction. The manager depends on this interface, never on
|
|
@@ -33451,6 +33615,91 @@ async function warmNodePty() {
|
|
|
33451
33615
|
await loadNodePty();
|
|
33452
33616
|
}
|
|
33453
33617
|
//#endregion
|
|
33618
|
+
//#region src/silence-analysis.ts
|
|
33619
|
+
/**
|
|
33620
|
+
* Every analyzer a Terminal camera is created with switched OFF.
|
|
33621
|
+
*
|
|
33622
|
+
* `motion-detection` sits first deliberately: it is the one that holds the
|
|
33623
|
+
* decode session open, so it is the one whose absence is the difference between
|
|
33624
|
+
* a terminal costing a screen scrape and costing a decode pipeline.
|
|
33625
|
+
*/
|
|
33626
|
+
var TERMINAL_SILENCED_CAP_NAMES = [
|
|
33627
|
+
"motion-detection",
|
|
33628
|
+
DETECTION_PIPELINE_CAP_NAME,
|
|
33629
|
+
AUDIO_ANALYSIS_CAP_NAME
|
|
33630
|
+
];
|
|
33631
|
+
/**
|
|
33632
|
+
* Resolve the addon currently providing `capName` for this device.
|
|
33633
|
+
*
|
|
33634
|
+
* Inlined rather than reached for through a helper because there is exactly one
|
|
33635
|
+
* honest source: the device's own bindings. `listBindableCapsForDeviceType`
|
|
33636
|
+
* answers for the device TYPE and would happily name a wrapper that is not the
|
|
33637
|
+
* one bound here.
|
|
33638
|
+
*
|
|
33639
|
+
* Returns `null` when the cap is not bound at all — which is not an error: a
|
|
33640
|
+
* deployment with no audio analyzer has nothing to switch off, and demanding one
|
|
33641
|
+
* would make every terminal creation fail on a perfectly valid hub.
|
|
33642
|
+
*/
|
|
33643
|
+
async function resolveBoundWrapper(api, deviceId, capName) {
|
|
33644
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
33645
|
+
for (const entry of bindings.entries) {
|
|
33646
|
+
if (entry.capName !== capName) continue;
|
|
33647
|
+
if (entry.kind !== "wrapped") continue;
|
|
33648
|
+
if (entry.providerAddonId === "") return null;
|
|
33649
|
+
return entry.providerAddonId;
|
|
33650
|
+
}
|
|
33651
|
+
return null;
|
|
33652
|
+
}
|
|
33653
|
+
/**
|
|
33654
|
+
* Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
|
|
33655
|
+
* Terminal camera.
|
|
33656
|
+
*
|
|
33657
|
+
* Called ONLY on creation. An operator who deliberately turns detection back on
|
|
33658
|
+
* for a terminal must win, and a reconcile that re-asserted every pass would
|
|
33659
|
+
* silently overrule them once a minute.
|
|
33660
|
+
*
|
|
33661
|
+
* @throws if a cap IS bound and the write to its authority failed. The camera
|
|
33662
|
+
* would then be running a full analyzer for a screen recording, which is the
|
|
33663
|
+
* exact cost this exists to remove — and a silent version of that failure is
|
|
33664
|
+
* unfindable.
|
|
33665
|
+
*/
|
|
33666
|
+
async function silenceAnalysisFor(deps, deviceId) {
|
|
33667
|
+
const failures = [];
|
|
33668
|
+
for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
|
|
33669
|
+
const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
|
|
33670
|
+
if (wrapperAddonId === null) {
|
|
33671
|
+
deps.logger.debug("terminal camera: no analyzer bound for this capability", {
|
|
33672
|
+
tags: { deviceId },
|
|
33673
|
+
meta: { capName }
|
|
33674
|
+
});
|
|
33675
|
+
continue;
|
|
33676
|
+
}
|
|
33677
|
+
await deps.api.deviceManager.setWrapperActive.mutate({
|
|
33678
|
+
deviceId,
|
|
33679
|
+
capName,
|
|
33680
|
+
wrapperAddonId,
|
|
33681
|
+
active: false
|
|
33682
|
+
});
|
|
33683
|
+
deps.logger.info("terminal camera: analyzer switched off at its authority", {
|
|
33684
|
+
tags: { deviceId },
|
|
33685
|
+
meta: {
|
|
33686
|
+
capName,
|
|
33687
|
+
wrapperAddonId
|
|
33688
|
+
}
|
|
33689
|
+
});
|
|
33690
|
+
} catch (err) {
|
|
33691
|
+
failures.push(`${capName}: ${errMsg(err)}`);
|
|
33692
|
+
deps.logger.error("terminal camera: could NOT switch an analyzer off", {
|
|
33693
|
+
tags: { deviceId },
|
|
33694
|
+
meta: {
|
|
33695
|
+
capName,
|
|
33696
|
+
error: errMsg(err)
|
|
33697
|
+
}
|
|
33698
|
+
});
|
|
33699
|
+
}
|
|
33700
|
+
if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
|
|
33701
|
+
}
|
|
33702
|
+
//#endregion
|
|
33454
33703
|
//#region src/terminal-camera-declarations.ts
|
|
33455
33704
|
/**
|
|
33456
33705
|
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
@@ -33485,6 +33734,198 @@ function buildTerminalInstanceCameraDeclarations(instances) {
|
|
|
33485
33734
|
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33486
33735
|
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33487
33736
|
}
|
|
33737
|
+
//#endregion
|
|
33738
|
+
//#region src/terminal-cell-runs.ts
|
|
33739
|
+
var TERMINAL_DEFAULT_FG = "#d7dce2";
|
|
33740
|
+
var TERMINAL_DEFAULT_BG = "#0b0d10";
|
|
33741
|
+
/**
|
|
33742
|
+
* The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
|
|
33743
|
+
* frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
|
|
33744
|
+
* whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
|
|
33745
|
+
* near-black background at 13px. Index 7 IS the default foreground, so plain
|
|
33746
|
+
* `CSI 37m` text renders identically to unstyled text.
|
|
33747
|
+
*/
|
|
33748
|
+
var TERMINAL_ANSI_PALETTE = [
|
|
33749
|
+
"#282c34",
|
|
33750
|
+
"#e06c75",
|
|
33751
|
+
"#98c379",
|
|
33752
|
+
"#e5c07b",
|
|
33753
|
+
"#61afef",
|
|
33754
|
+
"#c678dd",
|
|
33755
|
+
"#56b6c2",
|
|
33756
|
+
TERMINAL_DEFAULT_FG,
|
|
33757
|
+
"#5c6370",
|
|
33758
|
+
"#ef596f",
|
|
33759
|
+
"#89ca78",
|
|
33760
|
+
"#f0c674",
|
|
33761
|
+
"#6cb6ff",
|
|
33762
|
+
"#d55fde",
|
|
33763
|
+
"#2bbac5",
|
|
33764
|
+
"#ffffff"
|
|
33765
|
+
];
|
|
33766
|
+
/** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
|
|
33767
|
+
var TERMINAL_CUBE_LEVELS = [
|
|
33768
|
+
0,
|
|
33769
|
+
95,
|
|
33770
|
+
135,
|
|
33771
|
+
175,
|
|
33772
|
+
215,
|
|
33773
|
+
255
|
|
33774
|
+
];
|
|
33775
|
+
var TERMINAL_CUBE_FIRST = 16;
|
|
33776
|
+
var TERMINAL_GRAYSCALE_FIRST = 232;
|
|
33777
|
+
var TERMINAL_GRAYSCALE_BASE = 8;
|
|
33778
|
+
var TERMINAL_GRAYSCALE_STEP = 10;
|
|
33779
|
+
/** SGR 2 keeps the foreground legible; it must not become the background. */
|
|
33780
|
+
var TERMINAL_DIM_WEIGHT = .6;
|
|
33781
|
+
function channel(value) {
|
|
33782
|
+
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
33783
|
+
}
|
|
33784
|
+
function hex(red, green, blue) {
|
|
33785
|
+
return `#${channel(red)}${channel(green)}${channel(blue)}`;
|
|
33786
|
+
}
|
|
33787
|
+
function parseHex(color) {
|
|
33788
|
+
return [
|
|
33789
|
+
Number.parseInt(color.slice(1, 3), 16),
|
|
33790
|
+
Number.parseInt(color.slice(3, 5), 16),
|
|
33791
|
+
Number.parseInt(color.slice(5, 7), 16)
|
|
33792
|
+
];
|
|
33793
|
+
}
|
|
33794
|
+
/** Resolve an xterm palette index (0-255) to a hex colour. */
|
|
33795
|
+
function terminalPaletteColor(index) {
|
|
33796
|
+
const ansi = TERMINAL_ANSI_PALETTE[index];
|
|
33797
|
+
if (ansi !== void 0) return ansi;
|
|
33798
|
+
if (index >= TERMINAL_GRAYSCALE_FIRST) {
|
|
33799
|
+
const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
|
|
33800
|
+
return hex(level, level, level);
|
|
33801
|
+
}
|
|
33802
|
+
if (index >= TERMINAL_CUBE_FIRST) {
|
|
33803
|
+
const offset = index - TERMINAL_CUBE_FIRST;
|
|
33804
|
+
return hex(TERMINAL_CUBE_LEVELS[Math.floor(offset / 36) % 6] ?? 0, TERMINAL_CUBE_LEVELS[Math.floor(offset / 6) % 6] ?? 0, TERMINAL_CUBE_LEVELS[offset % 6] ?? 0);
|
|
33805
|
+
}
|
|
33806
|
+
return TERMINAL_DEFAULT_FG;
|
|
33807
|
+
}
|
|
33808
|
+
/** Resolve a 0xRRGGBB truecolor value to a hex colour. */
|
|
33809
|
+
function terminalRgbColor(value) {
|
|
33810
|
+
return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
|
|
33811
|
+
}
|
|
33812
|
+
function blend(color, toward, weight) {
|
|
33813
|
+
const [red, green, blue] = parseHex(color);
|
|
33814
|
+
const [targetRed, targetGreen, targetBlue] = parseHex(toward);
|
|
33815
|
+
return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
|
|
33816
|
+
}
|
|
33817
|
+
function resolveForeground(cell) {
|
|
33818
|
+
if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
|
|
33819
|
+
if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
|
|
33820
|
+
return TERMINAL_DEFAULT_FG;
|
|
33821
|
+
}
|
|
33822
|
+
function resolveBackground(cell) {
|
|
33823
|
+
if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
|
|
33824
|
+
if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
|
|
33825
|
+
return TERMINAL_DEFAULT_BG;
|
|
33826
|
+
}
|
|
33827
|
+
/**
|
|
33828
|
+
* Resolve one cell's attributes into concrete colours.
|
|
33829
|
+
*
|
|
33830
|
+
* Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
|
|
33831
|
+
* defaults is still a visible swap rather than a no-op — that is how a selected
|
|
33832
|
+
* or highlighted row in Glances reads. Invisible is then conceal-by-equality
|
|
33833
|
+
* (foreground painted in its own background): the cell keeps its columns, which
|
|
33834
|
+
* a dropped cell would not, and dropping it would shift the whole rest of the
|
|
33835
|
+
* row left.
|
|
33836
|
+
*/
|
|
33837
|
+
function resolveCellStyle(cell) {
|
|
33838
|
+
const inverse = cell.isInverse() !== 0;
|
|
33839
|
+
const plainFg = resolveForeground(cell);
|
|
33840
|
+
const plainBg = resolveBackground(cell);
|
|
33841
|
+
const background = inverse ? plainFg : plainBg;
|
|
33842
|
+
let foreground = inverse ? plainBg : plainFg;
|
|
33843
|
+
if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
|
|
33844
|
+
if (cell.isInvisible() !== 0) foreground = background;
|
|
33845
|
+
return {
|
|
33846
|
+
fg: foreground === "#d7dce2" ? null : foreground,
|
|
33847
|
+
bg: background === "#0b0d10" ? null : background,
|
|
33848
|
+
bold: cell.isBold() !== 0
|
|
33849
|
+
};
|
|
33850
|
+
}
|
|
33851
|
+
function sameStyle(left, right) {
|
|
33852
|
+
return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
|
|
33853
|
+
}
|
|
33854
|
+
/**
|
|
33855
|
+
* Merge adjacent same-style cells into runs, then drop the trailing run of
|
|
33856
|
+
* default-styled whitespace so a row costs what it draws — the same trim
|
|
33857
|
+
* `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
|
|
33858
|
+
* a green bar of spaces out to the right margin is a pixel Glances drew.
|
|
33859
|
+
*/
|
|
33860
|
+
function buildCellRuns(cells) {
|
|
33861
|
+
const runs = [];
|
|
33862
|
+
let text = "";
|
|
33863
|
+
let style = null;
|
|
33864
|
+
for (const cell of cells) {
|
|
33865
|
+
if (style !== null && sameStyle(style, cell.style)) {
|
|
33866
|
+
text += cell.text;
|
|
33867
|
+
continue;
|
|
33868
|
+
}
|
|
33869
|
+
if (style !== null) runs.push({
|
|
33870
|
+
text,
|
|
33871
|
+
...style
|
|
33872
|
+
});
|
|
33873
|
+
text = cell.text;
|
|
33874
|
+
style = cell.style;
|
|
33875
|
+
}
|
|
33876
|
+
if (style !== null) runs.push({
|
|
33877
|
+
text,
|
|
33878
|
+
...style
|
|
33879
|
+
});
|
|
33880
|
+
while (runs.length > 0) {
|
|
33881
|
+
const last = runs[runs.length - 1];
|
|
33882
|
+
if (last === void 0 || last.bg !== null) break;
|
|
33883
|
+
const trimmed = last.text.replace(/\s+$/u, "");
|
|
33884
|
+
if (trimmed === last.text) break;
|
|
33885
|
+
if (trimmed === "") {
|
|
33886
|
+
runs.pop();
|
|
33887
|
+
continue;
|
|
33888
|
+
}
|
|
33889
|
+
runs[runs.length - 1] = {
|
|
33890
|
+
...last,
|
|
33891
|
+
text: trimmed
|
|
33892
|
+
};
|
|
33893
|
+
break;
|
|
33894
|
+
}
|
|
33895
|
+
return runs;
|
|
33896
|
+
}
|
|
33897
|
+
/**
|
|
33898
|
+
* Monospace families to try, in order — NOT one family and a generic.
|
|
33899
|
+
*
|
|
33900
|
+
* A terminal screen is mostly box-drawing and block characters, and a font
|
|
33901
|
+
* without them renders the frame as noise rather than as missing detail.
|
|
33902
|
+
* `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
|
|
33903
|
+
* DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
|
|
33904
|
+
* coverage is not, and its Glances camera came out unreadable while the hub's
|
|
33905
|
+
* was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
|
|
33906
|
+
*
|
|
33907
|
+
* `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
|
|
33908
|
+
* present on every install, and derived from DejaVu Sans Mono — the same glyph
|
|
33909
|
+
* coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
|
|
33910
|
+
* generic stays last so a host with none of them still draws something.
|
|
33911
|
+
*/
|
|
33912
|
+
var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
|
|
33913
|
+
var TERMINAL_FONT_SIZE = 13;
|
|
33914
|
+
var TERMINAL_TEXT_MARGIN_X = 8;
|
|
33915
|
+
var TERMINAL_ROW_HEIGHT = 15;
|
|
33916
|
+
var TERMINAL_BASELINE_Y = 18;
|
|
33917
|
+
/**
|
|
33918
|
+
* Distance from a row's baseline up to the top of its cell box. Chosen so
|
|
33919
|
+
* consecutive rows tile exactly: row N's box runs from `baseline - this` for
|
|
33920
|
+
* `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
|
|
33921
|
+
* bar that stopped short would draw as stripes across a `CSI 42m` panel.
|
|
33922
|
+
*/
|
|
33923
|
+
var TERMINAL_CELL_ASCENT = 11.5;
|
|
33924
|
+
var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
|
|
33925
|
+
/** Two decimals is under a tenth of a pixel and keeps the SVG small. */
|
|
33926
|
+
function coordinate(value) {
|
|
33927
|
+
return String(Number(value.toFixed(2)));
|
|
33928
|
+
}
|
|
33488
33929
|
function escapeXml(value) {
|
|
33489
33930
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33490
33931
|
}
|
|
@@ -33499,11 +33940,47 @@ function escapeXml(value) {
|
|
|
33499
33940
|
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33500
33941
|
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33501
33942
|
* collapsed against 178 px preserved.
|
|
33502
|
-
|
|
33503
|
-
|
|
33504
|
-
|
|
33505
|
-
|
|
33506
|
-
|
|
33943
|
+
*
|
|
33944
|
+
* Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
|
|
33945
|
+
* never appended to the one before it, so the background rects and the glyphs
|
|
33946
|
+
* are placed off the same grid and cannot drift apart. `textLength` is emitted
|
|
33947
|
+
* with it because it is the correct declaration and renderers that honour it
|
|
33948
|
+
* get an exact grid — but it is not what makes this work: librsvg, which sharp
|
|
33949
|
+
* uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
|
|
33950
|
+
* 600 px still drew its natural 937 px). The anchoring is the guarantee.
|
|
33951
|
+
*/
|
|
33952
|
+
function renderTerminalSvg(rows) {
|
|
33953
|
+
const backgrounds = [];
|
|
33954
|
+
const texts = [];
|
|
33955
|
+
rows.slice(0, 40).forEach((row, index) => {
|
|
33956
|
+
const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
|
|
33957
|
+
const top = baseline - TERMINAL_CELL_ASCENT;
|
|
33958
|
+
let column = 0;
|
|
33959
|
+
for (const run of row) {
|
|
33960
|
+
if (column >= 120) break;
|
|
33961
|
+
const clipped = clipRun(run, 120 - column);
|
|
33962
|
+
const columns = [...clipped].length;
|
|
33963
|
+
if (columns === 0) continue;
|
|
33964
|
+
const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
|
|
33965
|
+
const width = columns * TERMINAL_CELL_WIDTH;
|
|
33966
|
+
if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
|
|
33967
|
+
if (clipped.trim() !== "") {
|
|
33968
|
+
const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
|
|
33969
|
+
const weight = run.bold ? " font-weight=\"bold\"" : "";
|
|
33970
|
+
texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
|
|
33971
|
+
}
|
|
33972
|
+
column += columns;
|
|
33973
|
+
}
|
|
33974
|
+
});
|
|
33975
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="${TERMINAL_DEFAULT_BG}"/>${backgrounds.join("")}<g fill="${TERMINAL_DEFAULT_FG}" font-family="${TERMINAL_FONT_STACK}" font-size="${String(TERMINAL_FONT_SIZE)}">${texts.join("")}</g></svg>`;
|
|
33976
|
+
}
|
|
33977
|
+
/** Cut a run to the columns still left in the row, by code point not unit. */
|
|
33978
|
+
function clipRun(run, remaining) {
|
|
33979
|
+
const points = [...run.text];
|
|
33980
|
+
return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
|
|
33981
|
+
}
|
|
33982
|
+
async function renderTerminalJpeg(rows) {
|
|
33983
|
+
return sharp(Buffer.from(renderTerminalSvg(rows))).jpeg({
|
|
33507
33984
|
quality: 82,
|
|
33508
33985
|
chromaSubsampling: "4:2:0"
|
|
33509
33986
|
}).toBuffer();
|
|
@@ -38409,6 +38886,40 @@ function createXtermScreen(cols, rows) {
|
|
|
38409
38886
|
for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
|
|
38410
38887
|
return lines;
|
|
38411
38888
|
},
|
|
38889
|
+
/**
|
|
38890
|
+
* The colour-bearing read. `translateToString` — the only cell read this
|
|
38891
|
+
* screen used to offer — keeps characters and discards every attribute,
|
|
38892
|
+
* which is why the terminal camera rendered a fully-coloured Glances in
|
|
38893
|
+
* grey. Walking cells is the only way to get the attributes back.
|
|
38894
|
+
*
|
|
38895
|
+
* A zero-width cell is the tail half of a wide glyph (CJK, some emoji);
|
|
38896
|
+
* its characters already came back with the leading cell, so skipping it
|
|
38897
|
+
* keeps the text right. `getNullCell()` is reused across the whole screen
|
|
38898
|
+
* because `getCell` allocates otherwise — 4800 objects per frame.
|
|
38899
|
+
*/
|
|
38900
|
+
styledLines: () => {
|
|
38901
|
+
const buffer = term.buffer.active;
|
|
38902
|
+
const scratch = buffer.getNullCell();
|
|
38903
|
+
const styled = [];
|
|
38904
|
+
for (let row = 0; row < term.rows; row += 1) {
|
|
38905
|
+
const line = buffer.getLine(buffer.viewportY + row);
|
|
38906
|
+
if (!line) {
|
|
38907
|
+
styled.push([]);
|
|
38908
|
+
continue;
|
|
38909
|
+
}
|
|
38910
|
+
const cells = [];
|
|
38911
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
38912
|
+
const cell = line.getCell(column, scratch);
|
|
38913
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
38914
|
+
cells.push({
|
|
38915
|
+
text: cell.getChars() || " ",
|
|
38916
|
+
style: resolveCellStyle(cell)
|
|
38917
|
+
});
|
|
38918
|
+
}
|
|
38919
|
+
styled.push(buildCellRuns(cells));
|
|
38920
|
+
}
|
|
38921
|
+
return styled;
|
|
38922
|
+
},
|
|
38412
38923
|
dispose: () => {
|
|
38413
38924
|
term.dispose();
|
|
38414
38925
|
}
|
|
@@ -38419,6 +38930,9 @@ function createXtermScreen(cols, rows) {
|
|
|
38419
38930
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
38420
38931
|
var SESSION_IDLE_MS = 3e4;
|
|
38421
38932
|
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38933
|
+
var SNAPSHOT_SETTLE_QUIET_MS = 300;
|
|
38934
|
+
var SNAPSHOT_SETTLE_BUDGET_MS = 3e3;
|
|
38935
|
+
var SNAPSHOT_SETTLE_MAX_PULLS = 20;
|
|
38422
38936
|
var CLOSE_RETRY_BASE_MS = 50;
|
|
38423
38937
|
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38424
38938
|
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
@@ -38543,20 +39057,36 @@ var TerminalCameraRelay = class {
|
|
|
38543
39057
|
if (state.openPromise === opening) state.openPromise = null;
|
|
38544
39058
|
}
|
|
38545
39059
|
}
|
|
39060
|
+
/** Replay one output batch into the relay screen; true = the session exited. */
|
|
39061
|
+
applyBatch(state, batch) {
|
|
39062
|
+
if (batch.reset) {
|
|
39063
|
+
state.screen.dispose();
|
|
39064
|
+
state.screen = createXtermScreen(120, 40);
|
|
39065
|
+
if (batch.snapshot) state.screen.write(batch.snapshot);
|
|
39066
|
+
}
|
|
39067
|
+
let exited = false;
|
|
39068
|
+
for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
|
|
39069
|
+
else {
|
|
39070
|
+
state.sessionId = null;
|
|
39071
|
+
exited = true;
|
|
39072
|
+
}
|
|
39073
|
+
state.cursor = exited ? 0 : batch.cursor;
|
|
39074
|
+
return exited;
|
|
39075
|
+
}
|
|
38546
39076
|
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38547
39077
|
if (state.framePromise) {
|
|
38548
39078
|
await state.framePromise;
|
|
38549
39079
|
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38550
39080
|
}
|
|
38551
39081
|
const render = async () => {
|
|
38552
|
-
const
|
|
39082
|
+
const initialPull = state.sessionId === null && waitForInitialOutput;
|
|
38553
39083
|
const sessionId = await this.ensureSession(state);
|
|
38554
39084
|
let batch;
|
|
38555
39085
|
try {
|
|
38556
39086
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38557
39087
|
sessionId,
|
|
38558
39088
|
afterSeq: state.cursor,
|
|
38559
|
-
...
|
|
39089
|
+
...initialPull ? {
|
|
38560
39090
|
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38561
39091
|
waitForOutput: true
|
|
38562
39092
|
} : {}
|
|
@@ -38578,21 +39108,29 @@ var TerminalCameraRelay = class {
|
|
|
38578
39108
|
state.cursor = 0;
|
|
38579
39109
|
throw error;
|
|
38580
39110
|
}
|
|
38581
|
-
|
|
38582
|
-
|
|
38583
|
-
|
|
38584
|
-
|
|
38585
|
-
|
|
38586
|
-
|
|
38587
|
-
|
|
38588
|
-
|
|
38589
|
-
|
|
38590
|
-
|
|
39111
|
+
let exited = this.applyBatch(state, batch);
|
|
39112
|
+
if (initialPull && !exited) {
|
|
39113
|
+
const deadline = Date.now() + SNAPSHOT_SETTLE_BUDGET_MS;
|
|
39114
|
+
for (let pulls = 0; pulls < SNAPSHOT_SETTLE_MAX_PULLS && Date.now() < deadline; pulls += 1) {
|
|
39115
|
+
let settle;
|
|
39116
|
+
try {
|
|
39117
|
+
settle = await this.api.pullOutput(state.nodeId, {
|
|
39118
|
+
sessionId,
|
|
39119
|
+
afterSeq: state.cursor,
|
|
39120
|
+
waitMs: SNAPSHOT_SETTLE_QUIET_MS,
|
|
39121
|
+
waitForOutput: true
|
|
39122
|
+
});
|
|
39123
|
+
} catch {
|
|
39124
|
+
break;
|
|
39125
|
+
}
|
|
39126
|
+
const active = settle.reset || settle.events.some((event) => event.kind === "data");
|
|
39127
|
+
exited = this.applyBatch(state, settle);
|
|
39128
|
+
if (exited || !active) break;
|
|
39129
|
+
}
|
|
38591
39130
|
}
|
|
38592
|
-
state.cursor = exited ? 0 : batch.cursor;
|
|
38593
39131
|
await state.screen.flush();
|
|
38594
39132
|
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38595
|
-
state.jpeg = await renderTerminalJpeg(state.screen.
|
|
39133
|
+
state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
|
|
38596
39134
|
state.renderedCursor = state.cursor;
|
|
38597
39135
|
}
|
|
38598
39136
|
return state.jpeg;
|
|
@@ -39041,7 +39579,8 @@ function buildProfiles(options) {
|
|
|
39041
39579
|
"-m",
|
|
39042
39580
|
"glances",
|
|
39043
39581
|
...options.glancesArgs ?? []
|
|
39044
|
-
]
|
|
39582
|
+
],
|
|
39583
|
+
...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
|
|
39045
39584
|
});
|
|
39046
39585
|
}
|
|
39047
39586
|
if (options.allowShell) profiles.push({
|
|
@@ -39126,6 +39665,7 @@ var TerminalSessionManager = class {
|
|
|
39126
39665
|
glancesPath: opts.glancesPath,
|
|
39127
39666
|
glancesArgs: opts.glancesArgs,
|
|
39128
39667
|
glancesPythonPath: opts.glancesPythonPath,
|
|
39668
|
+
glancesEnv: opts.glancesEnv,
|
|
39129
39669
|
allowShell: opts.allowShell,
|
|
39130
39670
|
shellPath: opts.shellPath,
|
|
39131
39671
|
customProfiles: opts.customProfiles,
|
|
@@ -39423,6 +39963,18 @@ var TerminalSessionManager = class {
|
|
|
39423
39963
|
};
|
|
39424
39964
|
//#endregion
|
|
39425
39965
|
//#region src/addon.ts
|
|
39966
|
+
/**
|
|
39967
|
+
* Terminal addon — hosts interactive pty sessions running an allowlisted
|
|
39968
|
+
* profile (`monitor` → `btm`), streamed to the Admin UI over the data plane.
|
|
39969
|
+
*
|
|
39970
|
+
* Phase 1 of docs/design/2026-07-26-terminal-session-design.md: a real TTY good
|
|
39971
|
+
* enough to run a full-screen program. Phase 2 (streaming a session as a camera)
|
|
39972
|
+
* is a separate device-provider concern and does not change this addon's cap.
|
|
39973
|
+
*
|
|
39974
|
+
* Its own package (not a builtin) because it carries a native dependency
|
|
39975
|
+
* (`node-pty`) and should update via `camstack deploy` independently of the
|
|
39976
|
+
* framework, exactly like the node-av decoder addon.
|
|
39977
|
+
*/
|
|
39426
39978
|
var DEFAULTS = {
|
|
39427
39979
|
btmPath: "",
|
|
39428
39980
|
btmEnabled: true,
|
|
@@ -39455,12 +40007,21 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39455
40007
|
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39456
40008
|
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
39457
40009
|
glancesPythonPath = "";
|
|
40010
|
+
glancesEnv;
|
|
39458
40011
|
constructor() {
|
|
39459
40012
|
super({ ...DEFAULTS });
|
|
39460
40013
|
}
|
|
39461
40014
|
async onInitialize() {
|
|
39462
40015
|
await warmNodePty();
|
|
39463
40016
|
this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
|
|
40017
|
+
const shimDir = cursesShimDir();
|
|
40018
|
+
this.glancesEnv = resolveGlancesCursesShimEnv({
|
|
40019
|
+
platform: process.platform,
|
|
40020
|
+
shimDir,
|
|
40021
|
+
shimDirExists: cursesShimDirExists(shimDir),
|
|
40022
|
+
existingPythonPath: process.env["PYTHONPATH"]
|
|
40023
|
+
});
|
|
40024
|
+
this.probeGlancesCursesColours();
|
|
39464
40025
|
const manager = new TerminalSessionManager({
|
|
39465
40026
|
spawn: createNodePtySpawner(),
|
|
39466
40027
|
screenFactory: createXtermScreen,
|
|
@@ -39476,6 +40037,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39476
40037
|
glancesPath: this.config.glancesPath,
|
|
39477
40038
|
glancesArgs: this.config.glancesArgs,
|
|
39478
40039
|
glancesPythonPath: this.glancesPythonPath,
|
|
40040
|
+
glancesEnv: this.glancesEnv,
|
|
39479
40041
|
allowShell: this.config.allowShell,
|
|
39480
40042
|
shellPath: this.config.shellPath,
|
|
39481
40043
|
maxSessions: this.config.maxSessions,
|
|
@@ -39521,6 +40083,39 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39521
40083
|
provider: manager
|
|
39522
40084
|
}];
|
|
39523
40085
|
}
|
|
40086
|
+
/**
|
|
40087
|
+
* Fire-and-forget canary: the managed python must see a colour-capable
|
|
40088
|
+
* curses, or glances paints its B&W theme (SGR conceal/reverse) and the
|
|
40089
|
+
* terminal camera frames lose their text. The shim filename is ABI-locked
|
|
40090
|
+
* (cpython-312), so a managed-python bump silently disables it — this probe
|
|
40091
|
+
* is what makes that failure loud instead of a garbled camera.
|
|
40092
|
+
*/
|
|
40093
|
+
probeGlancesCursesColours() {
|
|
40094
|
+
if (!this.glancesPythonPath) return;
|
|
40095
|
+
const probeEnv = {
|
|
40096
|
+
...process.env,
|
|
40097
|
+
...this.glancesEnv,
|
|
40098
|
+
TERM: "xterm-256color"
|
|
40099
|
+
};
|
|
40100
|
+
execFile(this.glancesPythonPath, ["-c", "import curses\ncurses.setupterm('xterm-256color')\nprint(curses.tigetnum('colors'))"], {
|
|
40101
|
+
env: probeEnv,
|
|
40102
|
+
timeout: 1e4
|
|
40103
|
+
}, (error, stdout) => {
|
|
40104
|
+
const colours = error ? NaN : Number.parseInt(stdout.trim(), 10);
|
|
40105
|
+
if (!Number.isFinite(colours) || colours <= 0) {
|
|
40106
|
+
this.ctx.logger.warn("glances curses reports no colours — terminal camera frames will render the B&W theme", { meta: {
|
|
40107
|
+
colours: Number.isFinite(colours) ? colours : null,
|
|
40108
|
+
shimApplied: this.glancesEnv !== void 0,
|
|
40109
|
+
error: error ? error instanceof Error ? error.message : String(error) : void 0
|
|
40110
|
+
} });
|
|
40111
|
+
return;
|
|
40112
|
+
}
|
|
40113
|
+
this.ctx.logger.debug("glances curses colours available", { meta: {
|
|
40114
|
+
colours,
|
|
40115
|
+
shimApplied: this.glancesEnv !== void 0
|
|
40116
|
+
} });
|
|
40117
|
+
});
|
|
40118
|
+
}
|
|
39524
40119
|
async onConfigChanged() {
|
|
39525
40120
|
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
39526
40121
|
this.manager?.reconfigureProfiles({
|
|
@@ -39534,6 +40129,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39534
40129
|
glancesPath: this.config.glancesPath,
|
|
39535
40130
|
glancesArgs: this.config.glancesArgs,
|
|
39536
40131
|
glancesPythonPath: this.glancesPythonPath,
|
|
40132
|
+
glancesEnv: this.glancesEnv,
|
|
39537
40133
|
allowShell: this.config.allowShell,
|
|
39538
40134
|
shellPath: this.config.shellPath,
|
|
39539
40135
|
maxSessions: this.config.maxSessions,
|
|
@@ -39637,37 +40233,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39637
40233
|
}
|
|
39638
40234
|
}
|
|
39639
40235
|
/**
|
|
39640
|
-
* A Terminal camera is a rendered screen.
|
|
39641
|
-
*
|
|
39642
|
-
*
|
|
39643
|
-
*
|
|
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.
|
|
40236
|
+
* A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
|
|
40237
|
+
* forever, at full cost — see `silence-analysis.ts` for the measurements, the
|
|
40238
|
+
* reason `motion-detection` is in the list, and why a failure THROWS instead
|
|
40239
|
+
* of warning.
|
|
39648
40240
|
*
|
|
39649
|
-
* Written
|
|
39650
|
-
*
|
|
39651
|
-
*
|
|
39652
|
-
*
|
|
39653
|
-
*
|
|
39654
|
-
*/
|
|
39655
|
-
|
|
39656
|
-
|
|
39657
|
-
|
|
39658
|
-
|
|
39659
|
-
|
|
39660
|
-
enabled: false
|
|
39661
|
-
});
|
|
39662
|
-
} catch (err) {
|
|
39663
|
-
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
39664
|
-
tags: { deviceId },
|
|
39665
|
-
meta: {
|
|
39666
|
-
switchId,
|
|
39667
|
-
error: err instanceof Error ? err.message : String(err)
|
|
39668
|
-
}
|
|
39669
|
-
});
|
|
39670
|
-
}
|
|
40241
|
+
* Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
|
|
40242
|
+
* the deprecated switch cap the previous version used (D113). Written ONLY on
|
|
40243
|
+
* creation: an operator who deliberately turns detection back on for a
|
|
40244
|
+
* terminal must win, and a reconcile that re-asserted every pass would
|
|
40245
|
+
* silently overrule them once a minute.
|
|
40246
|
+
*/
|
|
40247
|
+
silenceAnalysisFor(deviceId) {
|
|
40248
|
+
return silenceAnalysisFor({
|
|
40249
|
+
api: this.ctx.api,
|
|
40250
|
+
logger: this.ctx.logger
|
|
40251
|
+
}, deviceId);
|
|
39671
40252
|
}
|
|
39672
40253
|
terminalInstanceControl() {
|
|
39673
40254
|
return {
|
|
@@ -39980,4 +40561,4 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39980
40561
|
}
|
|
39981
40562
|
};
|
|
39982
40563
|
//#endregion
|
|
39983
|
-
export { TerminalAddon, createXtermScreen as a, createTerminalDataPlaneHandler as i, buildProfiles as n,
|
|
40564
|
+
export { TerminalAddon, createXtermScreen as a, buildCellRuns as c, terminalRgbColor as d, createNodePtySpawner as f, createTerminalDataPlaneHandler as i, resolveCellStyle as l, buildProfiles as n, TERMINAL_DEFAULT_BG as o, warmNodePty as p, findProfile as r, TERMINAL_DEFAULT_FG as s, TerminalSessionManager as t, terminalPaletteColor as u };
|