@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.js
CHANGED
|
@@ -22,9 +22,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
22
22
|
enumerable: true
|
|
23
23
|
}) : target, mod));
|
|
24
24
|
//#endregion
|
|
25
|
+
let node_child_process = require("node:child_process");
|
|
25
26
|
let node_fs = require("node:fs");
|
|
26
|
-
let node_module = require("node:module");
|
|
27
27
|
let node_path = require("node:path");
|
|
28
|
+
node_path = __toESM(node_path);
|
|
29
|
+
let node_url = require("node:url");
|
|
30
|
+
let node_module = require("node:module");
|
|
28
31
|
let sharp = require("sharp");
|
|
29
32
|
sharp = __toESM(sharp);
|
|
30
33
|
let node_http = require("node:http");
|
|
@@ -7044,6 +7047,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7044
7047
|
action: string().min(1),
|
|
7045
7048
|
input: unknown()
|
|
7046
7049
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7050
|
+
//#endregion
|
|
7051
|
+
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7052
|
+
/**
|
|
7053
|
+
import { errMsg } from '@camstack/types'
|
|
7054
|
+
* Extract a human-readable message from an unknown error value.
|
|
7055
|
+
* Replaces the ubiquitous `errMsg(err)` pattern.
|
|
7056
|
+
*/
|
|
7057
|
+
function errMsg(err) {
|
|
7058
|
+
if (err instanceof Error) return err.message;
|
|
7059
|
+
if (typeof err === "string") return err;
|
|
7060
|
+
return String(err);
|
|
7061
|
+
}
|
|
7047
7062
|
var EncodeProfileSchema = object({
|
|
7048
7063
|
video: object({
|
|
7049
7064
|
codec: _enum([
|
|
@@ -7281,8 +7296,31 @@ var AdoptionJobSchema = object({
|
|
|
7281
7296
|
error: string().nullable()
|
|
7282
7297
|
});
|
|
7283
7298
|
/**
|
|
7284
|
-
* Per-camera FUNCTION SWITCHES
|
|
7285
|
-
*
|
|
7299
|
+
* Per-camera FUNCTION SWITCHES.
|
|
7300
|
+
*
|
|
7301
|
+
* ## The aggregate group is being withdrawn — the BADGE is not (D113)
|
|
7302
|
+
*
|
|
7303
|
+
* This file shipped as "the one coherent on/off surface over the pipeline
|
|
7304
|
+
* functions an operator thinks in terms of". The operator's verdict on
|
|
7305
|
+
* 2026-08-12 was that the coherent surface bought complexity and no clarity:
|
|
7306
|
+
* every function already had a settings page of its own, and a second place to
|
|
7307
|
+
* turn it off is a second place to look. Each switch is going back to its own
|
|
7308
|
+
* component's original options — detection to the detection-pipeline wrapper
|
|
7309
|
+
* binding, audio analysis to its own, recording to `RecordingConfig.enabled`
|
|
7310
|
+
* (which was always first-class; the switch was a veneer over
|
|
7311
|
+
* `recording.setDeviceConfig`), notifications to a notification-center
|
|
7312
|
+
* per-device setting, the two camera planes to their own components.
|
|
7313
|
+
*
|
|
7314
|
+
* What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
|
|
7315
|
+
* thing that lets a status surface say DISABLED instead of BROKEN, recomposed
|
|
7316
|
+
* straight from the authorities with no group in the middle. That rule was
|
|
7317
|
+
* never about a control panel.
|
|
7318
|
+
*
|
|
7319
|
+
* Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
|
|
7320
|
+
* {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
|
|
7321
|
+
* `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
|
|
7322
|
+
* viewers (v1.0.305) and the admin UI still call it. It is deleted when they
|
|
7323
|
+
* stop; nothing new may be built on it.
|
|
7286
7324
|
*
|
|
7287
7325
|
* ## This file adds no state
|
|
7288
7326
|
*
|
|
@@ -7444,6 +7482,14 @@ var CameraSwitchGroupSchema = object({
|
|
|
7444
7482
|
fetchedAt: number()
|
|
7445
7483
|
});
|
|
7446
7484
|
/**
|
|
7485
|
+
* The wrapper capability each wrapper-backed switch controls. Named constants
|
|
7486
|
+
* because the same strings appear in `legacy-migrations.ts`, in
|
|
7487
|
+
* `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
|
|
7488
|
+
* them is a switch that silently writes a binding nobody reads.
|
|
7489
|
+
*/
|
|
7490
|
+
var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
|
|
7491
|
+
var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
|
|
7492
|
+
/**
|
|
7447
7493
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7448
7494
|
* recordings and events management surfaces.
|
|
7449
7495
|
*
|
|
@@ -17819,9 +17865,16 @@ var CameraStatusSchema = object({
|
|
|
17819
17865
|
audio: CameraAudioStatusSchema.nullable(),
|
|
17820
17866
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
17821
17867
|
/**
|
|
17822
|
-
* Per-camera
|
|
17868
|
+
* Per-camera functions an OPERATOR has turned off
|
|
17823
17869
|
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
17824
17870
|
*
|
|
17871
|
+
* Composed from the AUTHORITIES themselves — the wrapper bindings,
|
|
17872
|
+
* `RecordingConfig.enabled`, the notification mute, the broker's audio
|
|
17873
|
+
* policy, the camera's own microphone — via `composeSwitchedOff`, not from
|
|
17874
|
+
* the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
|
|
17875
|
+
* The badge outlives the control panel: the panel was a convenience, this is
|
|
17876
|
+
* the difference between a camera being off and a camera being dead.
|
|
17877
|
+
*
|
|
17825
17878
|
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
17826
17879
|
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
17827
17880
|
* `'object-detection'` was switched off by a person; the same camera with an
|
|
@@ -24908,10 +24961,42 @@ method(object({
|
|
|
24908
24961
|
*/
|
|
24909
24962
|
/** Playback-speed multiplier for the render (1 = realtime). */
|
|
24910
24963
|
var ExportSpeedSchema = number().min(.25).max(32);
|
|
24964
|
+
/**
|
|
24965
|
+
* One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
|
|
24966
|
+
*
|
|
24967
|
+
* Relative and not absolute epoch on purpose: the renderer's frame-select
|
|
24968
|
+
* expression sees ffmpeg's `t`, which starts at 0 for the export's source
|
|
24969
|
+
* playlist. Handing it absolute epochs would make every call site responsible
|
|
24970
|
+
* for the same subtraction, and the one that forgot would emit a filter that
|
|
24971
|
+
* selects nothing — silently, as a uniform timelapse.
|
|
24972
|
+
*/
|
|
24973
|
+
var ExportDenseRangeSchema = object({
|
|
24974
|
+
fromSec: number().nonnegative(),
|
|
24975
|
+
toSec: number().nonnegative()
|
|
24976
|
+
}).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
|
|
24977
|
+
/**
|
|
24978
|
+
* Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
|
|
24979
|
+
* listed ranges and at the base `everyMs` everywhere else.
|
|
24980
|
+
*
|
|
24981
|
+
* `everyMs` must be strictly smaller than the base cadence — a dense rate that
|
|
24982
|
+
* is not denser renders a uniform timelapse the operator believes is two-rate.
|
|
24983
|
+
*/
|
|
24984
|
+
var ExportDenseSchema = object({
|
|
24985
|
+
everyMs: number().int().positive(),
|
|
24986
|
+
ranges: array(ExportDenseRangeSchema).min(1).max(200)
|
|
24987
|
+
});
|
|
24911
24988
|
/** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
|
|
24912
24989
|
var ExportTimelapseSchema = object({
|
|
24913
24990
|
everyMs: number().int().positive(),
|
|
24914
|
-
outputFps: number().int().min(1).max(60).optional()
|
|
24991
|
+
outputFps: number().int().min(1).max(60).optional(),
|
|
24992
|
+
/** Optional second, FASTER rate over the intervals that matter. */
|
|
24993
|
+
dense: ExportDenseSchema.optional()
|
|
24994
|
+
}).superRefine((v, ctx) => {
|
|
24995
|
+
if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
|
|
24996
|
+
code: ZodIssueCode.custom,
|
|
24997
|
+
message: "dense.everyMs must be strictly smaller than the base everyMs",
|
|
24998
|
+
path: ["dense", "everyMs"]
|
|
24999
|
+
});
|
|
24915
25000
|
});
|
|
24916
25001
|
/**
|
|
24917
25002
|
* Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
|
|
@@ -24969,6 +25054,19 @@ var ExportDownloadSchema = object({
|
|
|
24969
25054
|
url: string(),
|
|
24970
25055
|
endpoints: array(string())
|
|
24971
25056
|
});
|
|
25057
|
+
/**
|
|
25058
|
+
* A finished export's bytes, inline.
|
|
25059
|
+
*
|
|
25060
|
+
* `bytes` is the DECODED length — the number the caller bounds and logs
|
|
25061
|
+
* against, so nobody has to infer it from the base64 length.
|
|
25062
|
+
*/
|
|
25063
|
+
var ExportBytesSchema = object({
|
|
25064
|
+
base64: string(),
|
|
25065
|
+
contentType: string(),
|
|
25066
|
+
/** Suggested filename, extension included. */
|
|
25067
|
+
name: string(),
|
|
25068
|
+
bytes: number().int().nonnegative()
|
|
25069
|
+
});
|
|
24972
25070
|
method(object({
|
|
24973
25071
|
deviceId: number(),
|
|
24974
25072
|
profile: string(),
|
|
@@ -24993,6 +25091,9 @@ method(object({
|
|
|
24993
25091
|
}), method(object({ exportId: string() }), ExportDownloadSchema, {
|
|
24994
25092
|
kind: "query",
|
|
24995
25093
|
auth: "protected"
|
|
25094
|
+
}), method(object({ exportId: string() }), ExportBytesSchema, {
|
|
25095
|
+
kind: "query",
|
|
25096
|
+
auth: "protected"
|
|
24996
25097
|
});
|
|
24997
25098
|
/**
|
|
24998
25099
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
@@ -31844,6 +31945,12 @@ Object.freeze({
|
|
|
31844
31945
|
addonId: null,
|
|
31845
31946
|
access: "view"
|
|
31846
31947
|
},
|
|
31948
|
+
"recordingExport.readExportBytes": {
|
|
31949
|
+
capName: "recordingExport",
|
|
31950
|
+
capScope: "system",
|
|
31951
|
+
addonId: null,
|
|
31952
|
+
access: "view"
|
|
31953
|
+
},
|
|
31847
31954
|
"sceneMonitor.captureReference": {
|
|
31848
31955
|
capName: "scene-monitor",
|
|
31849
31956
|
capScope: "device",
|
|
@@ -33261,10 +33368,28 @@ TimelapseRuleInputSchema.extend({
|
|
|
33261
33368
|
*/
|
|
33262
33369
|
ownerUserId: string().optional(),
|
|
33263
33370
|
/**
|
|
33264
|
-
* Epoch-ms of the
|
|
33265
|
-
*
|
|
33371
|
+
* Epoch-ms of the NEWEST successful generation across every camera of this
|
|
33372
|
+
* rule. What a UI shows, and the compatibility floor for
|
|
33373
|
+
* {@link readTimelapseGeneratedAt}. Absent = never generated.
|
|
33266
33374
|
*/
|
|
33267
33375
|
lastGeneratedAt: number().optional(),
|
|
33376
|
+
/**
|
|
33377
|
+
* PER-CAMERA generation state, keyed by `String(deviceId)` — the
|
|
33378
|
+
* re-generation guard's real durable state.
|
|
33379
|
+
*
|
|
33380
|
+
* One rule covers several cameras and each renders its own video, so a rule
|
|
33381
|
+
* -wide stamp is wrong in the direction that DESTROYS work: camera A
|
|
33382
|
+
* succeeding at 06:05 tells camera B, whose render failed, that it is
|
|
33383
|
+
* already done — and B's night is gone for good, because the window will not
|
|
33384
|
+
* come back.
|
|
33385
|
+
*
|
|
33386
|
+
* ADDITIVE, so the migration is free: a row written before this field simply
|
|
33387
|
+
* has no map, and {@link readTimelapseGeneratedAt} falls back to
|
|
33388
|
+
* {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
|
|
33389
|
+
* "never generated" would re-render and re-notify every camera of every rule
|
|
33390
|
+
* once, on the deploy that shipped the map.
|
|
33391
|
+
*/
|
|
33392
|
+
generatedByDevice: record(string(), number()).optional(),
|
|
33268
33393
|
/** userId of the caller who created the rule (server-stamped). */
|
|
33269
33394
|
createdBy: string(),
|
|
33270
33395
|
createdAt: number(),
|
|
@@ -33377,6 +33502,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
|
33377
33502
|
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
33378
33503
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
33379
33504
|
//#endregion
|
|
33505
|
+
//#region src/curses-shim.ts
|
|
33506
|
+
/**
|
|
33507
|
+
* macOS repair for the managed Python's curses.
|
|
33508
|
+
*
|
|
33509
|
+
* The python-build-standalone distribution the dependency resolver installs on
|
|
33510
|
+
* macOS links ncurses statically with the terminfo database compiled OUT
|
|
33511
|
+
* (`--disable-database`) and an ABI mismatch that misparses every numeric
|
|
33512
|
+
* capability of its five built-in fallback terminals (`colors=-1`,
|
|
33513
|
+
* `cols=524368`). `has_colors()` is therefore False under every TERM value and
|
|
33514
|
+
* `init_pair` is hard-blocked, so glances takes its B&W branch and paints the
|
|
33515
|
+
* whole screen with SGR conceal/reverse/alt-charset — the "text missing,
|
|
33516
|
+
* light bars instead" terminal camera frames. No environment variable can fix
|
|
33517
|
+
* it: the code that would read TERMINFO/TERMINFO_DIRS was never compiled in.
|
|
33518
|
+
*
|
|
33519
|
+
* The repair ships prebuilt `_curses` / `_curses_panel` extension modules
|
|
33520
|
+
* linked against the system `/usr/lib/libncurses.5.4.dylib` (universal2) and
|
|
33521
|
+
* puts their directory on PYTHONPATH at PTY spawn — PYTHONPATH precedes
|
|
33522
|
+
* lib-dynload on sys.path, so the working modules shadow the broken ones with
|
|
33523
|
+
* no change to the managed distribution. The filename is ABI-locked to
|
|
33524
|
+
* cpython-312: a managed-python bump to 3.13 makes the shim silently inert
|
|
33525
|
+
* (python ignores a mismatched ABI tag), which is why the addon probes
|
|
33526
|
+
* `tigetnum("colors")` at startup and logs loudly when colours are missing.
|
|
33527
|
+
*/
|
|
33528
|
+
/** `<addonRoot>/assets/curses-shim/py312`, valid from both src/ and dist/. */
|
|
33529
|
+
function cursesShimDir() {
|
|
33530
|
+
return node_path.default.join(node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)), "..", "assets", "curses-shim", "py312");
|
|
33531
|
+
}
|
|
33532
|
+
function cursesShimDirExists(shimDir) {
|
|
33533
|
+
return (0, node_fs.existsSync)(shimDir);
|
|
33534
|
+
}
|
|
33535
|
+
/**
|
|
33536
|
+
* The env to merge into the glances profile, or undefined when the shim does
|
|
33537
|
+
* not apply (non-macOS, or the assets were not shipped).
|
|
33538
|
+
*/
|
|
33539
|
+
function resolveGlancesCursesShimEnv(options) {
|
|
33540
|
+
if (options.platform !== "darwin" || !options.shimDirExists) return void 0;
|
|
33541
|
+
const existing = options.existingPythonPath?.trim();
|
|
33542
|
+
return { PYTHONPATH: existing ? `${options.shimDir}:${existing}` : options.shimDir };
|
|
33543
|
+
}
|
|
33544
|
+
//#endregion
|
|
33380
33545
|
//#region src/pty.ts
|
|
33381
33546
|
/**
|
|
33382
33547
|
* Minimal pty abstraction. The manager depends on this interface, never on
|
|
@@ -33473,6 +33638,91 @@ async function warmNodePty() {
|
|
|
33473
33638
|
await loadNodePty();
|
|
33474
33639
|
}
|
|
33475
33640
|
//#endregion
|
|
33641
|
+
//#region src/silence-analysis.ts
|
|
33642
|
+
/**
|
|
33643
|
+
* Every analyzer a Terminal camera is created with switched OFF.
|
|
33644
|
+
*
|
|
33645
|
+
* `motion-detection` sits first deliberately: it is the one that holds the
|
|
33646
|
+
* decode session open, so it is the one whose absence is the difference between
|
|
33647
|
+
* a terminal costing a screen scrape and costing a decode pipeline.
|
|
33648
|
+
*/
|
|
33649
|
+
var TERMINAL_SILENCED_CAP_NAMES = [
|
|
33650
|
+
"motion-detection",
|
|
33651
|
+
DETECTION_PIPELINE_CAP_NAME,
|
|
33652
|
+
AUDIO_ANALYSIS_CAP_NAME
|
|
33653
|
+
];
|
|
33654
|
+
/**
|
|
33655
|
+
* Resolve the addon currently providing `capName` for this device.
|
|
33656
|
+
*
|
|
33657
|
+
* Inlined rather than reached for through a helper because there is exactly one
|
|
33658
|
+
* honest source: the device's own bindings. `listBindableCapsForDeviceType`
|
|
33659
|
+
* answers for the device TYPE and would happily name a wrapper that is not the
|
|
33660
|
+
* one bound here.
|
|
33661
|
+
*
|
|
33662
|
+
* Returns `null` when the cap is not bound at all — which is not an error: a
|
|
33663
|
+
* deployment with no audio analyzer has nothing to switch off, and demanding one
|
|
33664
|
+
* would make every terminal creation fail on a perfectly valid hub.
|
|
33665
|
+
*/
|
|
33666
|
+
async function resolveBoundWrapper(api, deviceId, capName) {
|
|
33667
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
33668
|
+
for (const entry of bindings.entries) {
|
|
33669
|
+
if (entry.capName !== capName) continue;
|
|
33670
|
+
if (entry.kind !== "wrapped") continue;
|
|
33671
|
+
if (entry.providerAddonId === "") return null;
|
|
33672
|
+
return entry.providerAddonId;
|
|
33673
|
+
}
|
|
33674
|
+
return null;
|
|
33675
|
+
}
|
|
33676
|
+
/**
|
|
33677
|
+
* Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
|
|
33678
|
+
* Terminal camera.
|
|
33679
|
+
*
|
|
33680
|
+
* Called ONLY on creation. An operator who deliberately turns detection back on
|
|
33681
|
+
* for a terminal must win, and a reconcile that re-asserted every pass would
|
|
33682
|
+
* silently overrule them once a minute.
|
|
33683
|
+
*
|
|
33684
|
+
* @throws if a cap IS bound and the write to its authority failed. The camera
|
|
33685
|
+
* would then be running a full analyzer for a screen recording, which is the
|
|
33686
|
+
* exact cost this exists to remove — and a silent version of that failure is
|
|
33687
|
+
* unfindable.
|
|
33688
|
+
*/
|
|
33689
|
+
async function silenceAnalysisFor(deps, deviceId) {
|
|
33690
|
+
const failures = [];
|
|
33691
|
+
for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
|
|
33692
|
+
const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
|
|
33693
|
+
if (wrapperAddonId === null) {
|
|
33694
|
+
deps.logger.debug("terminal camera: no analyzer bound for this capability", {
|
|
33695
|
+
tags: { deviceId },
|
|
33696
|
+
meta: { capName }
|
|
33697
|
+
});
|
|
33698
|
+
continue;
|
|
33699
|
+
}
|
|
33700
|
+
await deps.api.deviceManager.setWrapperActive.mutate({
|
|
33701
|
+
deviceId,
|
|
33702
|
+
capName,
|
|
33703
|
+
wrapperAddonId,
|
|
33704
|
+
active: false
|
|
33705
|
+
});
|
|
33706
|
+
deps.logger.info("terminal camera: analyzer switched off at its authority", {
|
|
33707
|
+
tags: { deviceId },
|
|
33708
|
+
meta: {
|
|
33709
|
+
capName,
|
|
33710
|
+
wrapperAddonId
|
|
33711
|
+
}
|
|
33712
|
+
});
|
|
33713
|
+
} catch (err) {
|
|
33714
|
+
failures.push(`${capName}: ${errMsg(err)}`);
|
|
33715
|
+
deps.logger.error("terminal camera: could NOT switch an analyzer off", {
|
|
33716
|
+
tags: { deviceId },
|
|
33717
|
+
meta: {
|
|
33718
|
+
capName,
|
|
33719
|
+
error: errMsg(err)
|
|
33720
|
+
}
|
|
33721
|
+
});
|
|
33722
|
+
}
|
|
33723
|
+
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("; ")})`);
|
|
33724
|
+
}
|
|
33725
|
+
//#endregion
|
|
33476
33726
|
//#region src/terminal-camera-declarations.ts
|
|
33477
33727
|
/**
|
|
33478
33728
|
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
@@ -33507,6 +33757,198 @@ function buildTerminalInstanceCameraDeclarations(instances) {
|
|
|
33507
33757
|
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33508
33758
|
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33509
33759
|
}
|
|
33760
|
+
//#endregion
|
|
33761
|
+
//#region src/terminal-cell-runs.ts
|
|
33762
|
+
var TERMINAL_DEFAULT_FG = "#d7dce2";
|
|
33763
|
+
var TERMINAL_DEFAULT_BG = "#0b0d10";
|
|
33764
|
+
/**
|
|
33765
|
+
* The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
|
|
33766
|
+
* frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
|
|
33767
|
+
* whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
|
|
33768
|
+
* near-black background at 13px. Index 7 IS the default foreground, so plain
|
|
33769
|
+
* `CSI 37m` text renders identically to unstyled text.
|
|
33770
|
+
*/
|
|
33771
|
+
var TERMINAL_ANSI_PALETTE = [
|
|
33772
|
+
"#282c34",
|
|
33773
|
+
"#e06c75",
|
|
33774
|
+
"#98c379",
|
|
33775
|
+
"#e5c07b",
|
|
33776
|
+
"#61afef",
|
|
33777
|
+
"#c678dd",
|
|
33778
|
+
"#56b6c2",
|
|
33779
|
+
TERMINAL_DEFAULT_FG,
|
|
33780
|
+
"#5c6370",
|
|
33781
|
+
"#ef596f",
|
|
33782
|
+
"#89ca78",
|
|
33783
|
+
"#f0c674",
|
|
33784
|
+
"#6cb6ff",
|
|
33785
|
+
"#d55fde",
|
|
33786
|
+
"#2bbac5",
|
|
33787
|
+
"#ffffff"
|
|
33788
|
+
];
|
|
33789
|
+
/** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
|
|
33790
|
+
var TERMINAL_CUBE_LEVELS = [
|
|
33791
|
+
0,
|
|
33792
|
+
95,
|
|
33793
|
+
135,
|
|
33794
|
+
175,
|
|
33795
|
+
215,
|
|
33796
|
+
255
|
|
33797
|
+
];
|
|
33798
|
+
var TERMINAL_CUBE_FIRST = 16;
|
|
33799
|
+
var TERMINAL_GRAYSCALE_FIRST = 232;
|
|
33800
|
+
var TERMINAL_GRAYSCALE_BASE = 8;
|
|
33801
|
+
var TERMINAL_GRAYSCALE_STEP = 10;
|
|
33802
|
+
/** SGR 2 keeps the foreground legible; it must not become the background. */
|
|
33803
|
+
var TERMINAL_DIM_WEIGHT = .6;
|
|
33804
|
+
function channel(value) {
|
|
33805
|
+
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
33806
|
+
}
|
|
33807
|
+
function hex(red, green, blue) {
|
|
33808
|
+
return `#${channel(red)}${channel(green)}${channel(blue)}`;
|
|
33809
|
+
}
|
|
33810
|
+
function parseHex(color) {
|
|
33811
|
+
return [
|
|
33812
|
+
Number.parseInt(color.slice(1, 3), 16),
|
|
33813
|
+
Number.parseInt(color.slice(3, 5), 16),
|
|
33814
|
+
Number.parseInt(color.slice(5, 7), 16)
|
|
33815
|
+
];
|
|
33816
|
+
}
|
|
33817
|
+
/** Resolve an xterm palette index (0-255) to a hex colour. */
|
|
33818
|
+
function terminalPaletteColor(index) {
|
|
33819
|
+
const ansi = TERMINAL_ANSI_PALETTE[index];
|
|
33820
|
+
if (ansi !== void 0) return ansi;
|
|
33821
|
+
if (index >= TERMINAL_GRAYSCALE_FIRST) {
|
|
33822
|
+
const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
|
|
33823
|
+
return hex(level, level, level);
|
|
33824
|
+
}
|
|
33825
|
+
if (index >= TERMINAL_CUBE_FIRST) {
|
|
33826
|
+
const offset = index - TERMINAL_CUBE_FIRST;
|
|
33827
|
+
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);
|
|
33828
|
+
}
|
|
33829
|
+
return TERMINAL_DEFAULT_FG;
|
|
33830
|
+
}
|
|
33831
|
+
/** Resolve a 0xRRGGBB truecolor value to a hex colour. */
|
|
33832
|
+
function terminalRgbColor(value) {
|
|
33833
|
+
return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
|
|
33834
|
+
}
|
|
33835
|
+
function blend(color, toward, weight) {
|
|
33836
|
+
const [red, green, blue] = parseHex(color);
|
|
33837
|
+
const [targetRed, targetGreen, targetBlue] = parseHex(toward);
|
|
33838
|
+
return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
|
|
33839
|
+
}
|
|
33840
|
+
function resolveForeground(cell) {
|
|
33841
|
+
if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
|
|
33842
|
+
if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
|
|
33843
|
+
return TERMINAL_DEFAULT_FG;
|
|
33844
|
+
}
|
|
33845
|
+
function resolveBackground(cell) {
|
|
33846
|
+
if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
|
|
33847
|
+
if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
|
|
33848
|
+
return TERMINAL_DEFAULT_BG;
|
|
33849
|
+
}
|
|
33850
|
+
/**
|
|
33851
|
+
* Resolve one cell's attributes into concrete colours.
|
|
33852
|
+
*
|
|
33853
|
+
* Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
|
|
33854
|
+
* defaults is still a visible swap rather than a no-op — that is how a selected
|
|
33855
|
+
* or highlighted row in Glances reads. Invisible is then conceal-by-equality
|
|
33856
|
+
* (foreground painted in its own background): the cell keeps its columns, which
|
|
33857
|
+
* a dropped cell would not, and dropping it would shift the whole rest of the
|
|
33858
|
+
* row left.
|
|
33859
|
+
*/
|
|
33860
|
+
function resolveCellStyle(cell) {
|
|
33861
|
+
const inverse = cell.isInverse() !== 0;
|
|
33862
|
+
const plainFg = resolveForeground(cell);
|
|
33863
|
+
const plainBg = resolveBackground(cell);
|
|
33864
|
+
const background = inverse ? plainFg : plainBg;
|
|
33865
|
+
let foreground = inverse ? plainBg : plainFg;
|
|
33866
|
+
if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
|
|
33867
|
+
if (cell.isInvisible() !== 0) foreground = background;
|
|
33868
|
+
return {
|
|
33869
|
+
fg: foreground === "#d7dce2" ? null : foreground,
|
|
33870
|
+
bg: background === "#0b0d10" ? null : background,
|
|
33871
|
+
bold: cell.isBold() !== 0
|
|
33872
|
+
};
|
|
33873
|
+
}
|
|
33874
|
+
function sameStyle(left, right) {
|
|
33875
|
+
return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
|
|
33876
|
+
}
|
|
33877
|
+
/**
|
|
33878
|
+
* Merge adjacent same-style cells into runs, then drop the trailing run of
|
|
33879
|
+
* default-styled whitespace so a row costs what it draws — the same trim
|
|
33880
|
+
* `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
|
|
33881
|
+
* a green bar of spaces out to the right margin is a pixel Glances drew.
|
|
33882
|
+
*/
|
|
33883
|
+
function buildCellRuns(cells) {
|
|
33884
|
+
const runs = [];
|
|
33885
|
+
let text = "";
|
|
33886
|
+
let style = null;
|
|
33887
|
+
for (const cell of cells) {
|
|
33888
|
+
if (style !== null && sameStyle(style, cell.style)) {
|
|
33889
|
+
text += cell.text;
|
|
33890
|
+
continue;
|
|
33891
|
+
}
|
|
33892
|
+
if (style !== null) runs.push({
|
|
33893
|
+
text,
|
|
33894
|
+
...style
|
|
33895
|
+
});
|
|
33896
|
+
text = cell.text;
|
|
33897
|
+
style = cell.style;
|
|
33898
|
+
}
|
|
33899
|
+
if (style !== null) runs.push({
|
|
33900
|
+
text,
|
|
33901
|
+
...style
|
|
33902
|
+
});
|
|
33903
|
+
while (runs.length > 0) {
|
|
33904
|
+
const last = runs[runs.length - 1];
|
|
33905
|
+
if (last === void 0 || last.bg !== null) break;
|
|
33906
|
+
const trimmed = last.text.replace(/\s+$/u, "");
|
|
33907
|
+
if (trimmed === last.text) break;
|
|
33908
|
+
if (trimmed === "") {
|
|
33909
|
+
runs.pop();
|
|
33910
|
+
continue;
|
|
33911
|
+
}
|
|
33912
|
+
runs[runs.length - 1] = {
|
|
33913
|
+
...last,
|
|
33914
|
+
text: trimmed
|
|
33915
|
+
};
|
|
33916
|
+
break;
|
|
33917
|
+
}
|
|
33918
|
+
return runs;
|
|
33919
|
+
}
|
|
33920
|
+
/**
|
|
33921
|
+
* Monospace families to try, in order — NOT one family and a generic.
|
|
33922
|
+
*
|
|
33923
|
+
* A terminal screen is mostly box-drawing and block characters, and a font
|
|
33924
|
+
* without them renders the frame as noise rather than as missing detail.
|
|
33925
|
+
* `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
|
|
33926
|
+
* DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
|
|
33927
|
+
* coverage is not, and its Glances camera came out unreadable while the hub's
|
|
33928
|
+
* was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
|
|
33929
|
+
*
|
|
33930
|
+
* `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
|
|
33931
|
+
* present on every install, and derived from DejaVu Sans Mono — the same glyph
|
|
33932
|
+
* coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
|
|
33933
|
+
* generic stays last so a host with none of them still draws something.
|
|
33934
|
+
*/
|
|
33935
|
+
var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
|
|
33936
|
+
var TERMINAL_FONT_SIZE = 13;
|
|
33937
|
+
var TERMINAL_TEXT_MARGIN_X = 8;
|
|
33938
|
+
var TERMINAL_ROW_HEIGHT = 15;
|
|
33939
|
+
var TERMINAL_BASELINE_Y = 18;
|
|
33940
|
+
/**
|
|
33941
|
+
* Distance from a row's baseline up to the top of its cell box. Chosen so
|
|
33942
|
+
* consecutive rows tile exactly: row N's box runs from `baseline - this` for
|
|
33943
|
+
* `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
|
|
33944
|
+
* bar that stopped short would draw as stripes across a `CSI 42m` panel.
|
|
33945
|
+
*/
|
|
33946
|
+
var TERMINAL_CELL_ASCENT = 11.5;
|
|
33947
|
+
var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
|
|
33948
|
+
/** Two decimals is under a tenth of a pixel and keeps the SVG small. */
|
|
33949
|
+
function coordinate(value) {
|
|
33950
|
+
return String(Number(value.toFixed(2)));
|
|
33951
|
+
}
|
|
33510
33952
|
function escapeXml(value) {
|
|
33511
33953
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33512
33954
|
}
|
|
@@ -33521,11 +33963,47 @@ function escapeXml(value) {
|
|
|
33521
33963
|
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33522
33964
|
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33523
33965
|
* collapsed against 178 px preserved.
|
|
33524
|
-
|
|
33525
|
-
|
|
33526
|
-
|
|
33527
|
-
|
|
33528
|
-
|
|
33966
|
+
*
|
|
33967
|
+
* Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
|
|
33968
|
+
* never appended to the one before it, so the background rects and the glyphs
|
|
33969
|
+
* are placed off the same grid and cannot drift apart. `textLength` is emitted
|
|
33970
|
+
* with it because it is the correct declaration and renderers that honour it
|
|
33971
|
+
* get an exact grid — but it is not what makes this work: librsvg, which sharp
|
|
33972
|
+
* uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
|
|
33973
|
+
* 600 px still drew its natural 937 px). The anchoring is the guarantee.
|
|
33974
|
+
*/
|
|
33975
|
+
function renderTerminalSvg(rows) {
|
|
33976
|
+
const backgrounds = [];
|
|
33977
|
+
const texts = [];
|
|
33978
|
+
rows.slice(0, 40).forEach((row, index) => {
|
|
33979
|
+
const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
|
|
33980
|
+
const top = baseline - TERMINAL_CELL_ASCENT;
|
|
33981
|
+
let column = 0;
|
|
33982
|
+
for (const run of row) {
|
|
33983
|
+
if (column >= 120) break;
|
|
33984
|
+
const clipped = clipRun(run, 120 - column);
|
|
33985
|
+
const columns = [...clipped].length;
|
|
33986
|
+
if (columns === 0) continue;
|
|
33987
|
+
const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
|
|
33988
|
+
const width = columns * TERMINAL_CELL_WIDTH;
|
|
33989
|
+
if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
|
|
33990
|
+
if (clipped.trim() !== "") {
|
|
33991
|
+
const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
|
|
33992
|
+
const weight = run.bold ? " font-weight=\"bold\"" : "";
|
|
33993
|
+
texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
|
|
33994
|
+
}
|
|
33995
|
+
column += columns;
|
|
33996
|
+
}
|
|
33997
|
+
});
|
|
33998
|
+
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>`;
|
|
33999
|
+
}
|
|
34000
|
+
/** Cut a run to the columns still left in the row, by code point not unit. */
|
|
34001
|
+
function clipRun(run, remaining) {
|
|
34002
|
+
const points = [...run.text];
|
|
34003
|
+
return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
|
|
34004
|
+
}
|
|
34005
|
+
async function renderTerminalJpeg(rows) {
|
|
34006
|
+
return (0, sharp.default)(Buffer.from(renderTerminalSvg(rows))).jpeg({
|
|
33529
34007
|
quality: 82,
|
|
33530
34008
|
chromaSubsampling: "4:2:0"
|
|
33531
34009
|
}).toBuffer();
|
|
@@ -38431,6 +38909,40 @@ function createXtermScreen(cols, rows) {
|
|
|
38431
38909
|
for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
|
|
38432
38910
|
return lines;
|
|
38433
38911
|
},
|
|
38912
|
+
/**
|
|
38913
|
+
* The colour-bearing read. `translateToString` — the only cell read this
|
|
38914
|
+
* screen used to offer — keeps characters and discards every attribute,
|
|
38915
|
+
* which is why the terminal camera rendered a fully-coloured Glances in
|
|
38916
|
+
* grey. Walking cells is the only way to get the attributes back.
|
|
38917
|
+
*
|
|
38918
|
+
* A zero-width cell is the tail half of a wide glyph (CJK, some emoji);
|
|
38919
|
+
* its characters already came back with the leading cell, so skipping it
|
|
38920
|
+
* keeps the text right. `getNullCell()` is reused across the whole screen
|
|
38921
|
+
* because `getCell` allocates otherwise — 4800 objects per frame.
|
|
38922
|
+
*/
|
|
38923
|
+
styledLines: () => {
|
|
38924
|
+
const buffer = term.buffer.active;
|
|
38925
|
+
const scratch = buffer.getNullCell();
|
|
38926
|
+
const styled = [];
|
|
38927
|
+
for (let row = 0; row < term.rows; row += 1) {
|
|
38928
|
+
const line = buffer.getLine(buffer.viewportY + row);
|
|
38929
|
+
if (!line) {
|
|
38930
|
+
styled.push([]);
|
|
38931
|
+
continue;
|
|
38932
|
+
}
|
|
38933
|
+
const cells = [];
|
|
38934
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
38935
|
+
const cell = line.getCell(column, scratch);
|
|
38936
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
38937
|
+
cells.push({
|
|
38938
|
+
text: cell.getChars() || " ",
|
|
38939
|
+
style: resolveCellStyle(cell)
|
|
38940
|
+
});
|
|
38941
|
+
}
|
|
38942
|
+
styled.push(buildCellRuns(cells));
|
|
38943
|
+
}
|
|
38944
|
+
return styled;
|
|
38945
|
+
},
|
|
38434
38946
|
dispose: () => {
|
|
38435
38947
|
term.dispose();
|
|
38436
38948
|
}
|
|
@@ -38441,6 +38953,9 @@ function createXtermScreen(cols, rows) {
|
|
|
38441
38953
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
38442
38954
|
var SESSION_IDLE_MS = 3e4;
|
|
38443
38955
|
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38956
|
+
var SNAPSHOT_SETTLE_QUIET_MS = 300;
|
|
38957
|
+
var SNAPSHOT_SETTLE_BUDGET_MS = 3e3;
|
|
38958
|
+
var SNAPSHOT_SETTLE_MAX_PULLS = 20;
|
|
38444
38959
|
var CLOSE_RETRY_BASE_MS = 50;
|
|
38445
38960
|
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38446
38961
|
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
@@ -38565,20 +39080,36 @@ var TerminalCameraRelay = class {
|
|
|
38565
39080
|
if (state.openPromise === opening) state.openPromise = null;
|
|
38566
39081
|
}
|
|
38567
39082
|
}
|
|
39083
|
+
/** Replay one output batch into the relay screen; true = the session exited. */
|
|
39084
|
+
applyBatch(state, batch) {
|
|
39085
|
+
if (batch.reset) {
|
|
39086
|
+
state.screen.dispose();
|
|
39087
|
+
state.screen = createXtermScreen(120, 40);
|
|
39088
|
+
if (batch.snapshot) state.screen.write(batch.snapshot);
|
|
39089
|
+
}
|
|
39090
|
+
let exited = false;
|
|
39091
|
+
for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
|
|
39092
|
+
else {
|
|
39093
|
+
state.sessionId = null;
|
|
39094
|
+
exited = true;
|
|
39095
|
+
}
|
|
39096
|
+
state.cursor = exited ? 0 : batch.cursor;
|
|
39097
|
+
return exited;
|
|
39098
|
+
}
|
|
38568
39099
|
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38569
39100
|
if (state.framePromise) {
|
|
38570
39101
|
await state.framePromise;
|
|
38571
39102
|
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38572
39103
|
}
|
|
38573
39104
|
const render = async () => {
|
|
38574
|
-
const
|
|
39105
|
+
const initialPull = state.sessionId === null && waitForInitialOutput;
|
|
38575
39106
|
const sessionId = await this.ensureSession(state);
|
|
38576
39107
|
let batch;
|
|
38577
39108
|
try {
|
|
38578
39109
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38579
39110
|
sessionId,
|
|
38580
39111
|
afterSeq: state.cursor,
|
|
38581
|
-
...
|
|
39112
|
+
...initialPull ? {
|
|
38582
39113
|
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38583
39114
|
waitForOutput: true
|
|
38584
39115
|
} : {}
|
|
@@ -38600,21 +39131,29 @@ var TerminalCameraRelay = class {
|
|
|
38600
39131
|
state.cursor = 0;
|
|
38601
39132
|
throw error;
|
|
38602
39133
|
}
|
|
38603
|
-
|
|
38604
|
-
|
|
38605
|
-
|
|
38606
|
-
|
|
38607
|
-
|
|
38608
|
-
|
|
38609
|
-
|
|
38610
|
-
|
|
38611
|
-
|
|
38612
|
-
|
|
39134
|
+
let exited = this.applyBatch(state, batch);
|
|
39135
|
+
if (initialPull && !exited) {
|
|
39136
|
+
const deadline = Date.now() + SNAPSHOT_SETTLE_BUDGET_MS;
|
|
39137
|
+
for (let pulls = 0; pulls < SNAPSHOT_SETTLE_MAX_PULLS && Date.now() < deadline; pulls += 1) {
|
|
39138
|
+
let settle;
|
|
39139
|
+
try {
|
|
39140
|
+
settle = await this.api.pullOutput(state.nodeId, {
|
|
39141
|
+
sessionId,
|
|
39142
|
+
afterSeq: state.cursor,
|
|
39143
|
+
waitMs: SNAPSHOT_SETTLE_QUIET_MS,
|
|
39144
|
+
waitForOutput: true
|
|
39145
|
+
});
|
|
39146
|
+
} catch {
|
|
39147
|
+
break;
|
|
39148
|
+
}
|
|
39149
|
+
const active = settle.reset || settle.events.some((event) => event.kind === "data");
|
|
39150
|
+
exited = this.applyBatch(state, settle);
|
|
39151
|
+
if (exited || !active) break;
|
|
39152
|
+
}
|
|
38613
39153
|
}
|
|
38614
|
-
state.cursor = exited ? 0 : batch.cursor;
|
|
38615
39154
|
await state.screen.flush();
|
|
38616
39155
|
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38617
|
-
state.jpeg = await renderTerminalJpeg(state.screen.
|
|
39156
|
+
state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
|
|
38618
39157
|
state.renderedCursor = state.cursor;
|
|
38619
39158
|
}
|
|
38620
39159
|
return state.jpeg;
|
|
@@ -39063,7 +39602,8 @@ function buildProfiles(options) {
|
|
|
39063
39602
|
"-m",
|
|
39064
39603
|
"glances",
|
|
39065
39604
|
...options.glancesArgs ?? []
|
|
39066
|
-
]
|
|
39605
|
+
],
|
|
39606
|
+
...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
|
|
39067
39607
|
});
|
|
39068
39608
|
}
|
|
39069
39609
|
if (options.allowShell) profiles.push({
|
|
@@ -39148,6 +39688,7 @@ var TerminalSessionManager = class {
|
|
|
39148
39688
|
glancesPath: opts.glancesPath,
|
|
39149
39689
|
glancesArgs: opts.glancesArgs,
|
|
39150
39690
|
glancesPythonPath: opts.glancesPythonPath,
|
|
39691
|
+
glancesEnv: opts.glancesEnv,
|
|
39151
39692
|
allowShell: opts.allowShell,
|
|
39152
39693
|
shellPath: opts.shellPath,
|
|
39153
39694
|
customProfiles: opts.customProfiles,
|
|
@@ -39445,6 +39986,18 @@ var TerminalSessionManager = class {
|
|
|
39445
39986
|
};
|
|
39446
39987
|
//#endregion
|
|
39447
39988
|
//#region src/addon.ts
|
|
39989
|
+
/**
|
|
39990
|
+
* Terminal addon — hosts interactive pty sessions running an allowlisted
|
|
39991
|
+
* profile (`monitor` → `btm`), streamed to the Admin UI over the data plane.
|
|
39992
|
+
*
|
|
39993
|
+
* Phase 1 of docs/design/2026-07-26-terminal-session-design.md: a real TTY good
|
|
39994
|
+
* enough to run a full-screen program. Phase 2 (streaming a session as a camera)
|
|
39995
|
+
* is a separate device-provider concern and does not change this addon's cap.
|
|
39996
|
+
*
|
|
39997
|
+
* Its own package (not a builtin) because it carries a native dependency
|
|
39998
|
+
* (`node-pty`) and should update via `camstack deploy` independently of the
|
|
39999
|
+
* framework, exactly like the node-av decoder addon.
|
|
40000
|
+
*/
|
|
39448
40001
|
var DEFAULTS = {
|
|
39449
40002
|
btmPath: "",
|
|
39450
40003
|
btmEnabled: true,
|
|
@@ -39477,12 +40030,21 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39477
40030
|
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39478
40031
|
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
39479
40032
|
glancesPythonPath = "";
|
|
40033
|
+
glancesEnv;
|
|
39480
40034
|
constructor() {
|
|
39481
40035
|
super({ ...DEFAULTS });
|
|
39482
40036
|
}
|
|
39483
40037
|
async onInitialize() {
|
|
39484
40038
|
await warmNodePty();
|
|
39485
40039
|
this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
|
|
40040
|
+
const shimDir = cursesShimDir();
|
|
40041
|
+
this.glancesEnv = resolveGlancesCursesShimEnv({
|
|
40042
|
+
platform: process.platform,
|
|
40043
|
+
shimDir,
|
|
40044
|
+
shimDirExists: cursesShimDirExists(shimDir),
|
|
40045
|
+
existingPythonPath: process.env["PYTHONPATH"]
|
|
40046
|
+
});
|
|
40047
|
+
this.probeGlancesCursesColours();
|
|
39486
40048
|
const manager = new TerminalSessionManager({
|
|
39487
40049
|
spawn: createNodePtySpawner(),
|
|
39488
40050
|
screenFactory: createXtermScreen,
|
|
@@ -39498,6 +40060,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39498
40060
|
glancesPath: this.config.glancesPath,
|
|
39499
40061
|
glancesArgs: this.config.glancesArgs,
|
|
39500
40062
|
glancesPythonPath: this.glancesPythonPath,
|
|
40063
|
+
glancesEnv: this.glancesEnv,
|
|
39501
40064
|
allowShell: this.config.allowShell,
|
|
39502
40065
|
shellPath: this.config.shellPath,
|
|
39503
40066
|
maxSessions: this.config.maxSessions,
|
|
@@ -39543,6 +40106,39 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39543
40106
|
provider: manager
|
|
39544
40107
|
}];
|
|
39545
40108
|
}
|
|
40109
|
+
/**
|
|
40110
|
+
* Fire-and-forget canary: the managed python must see a colour-capable
|
|
40111
|
+
* curses, or glances paints its B&W theme (SGR conceal/reverse) and the
|
|
40112
|
+
* terminal camera frames lose their text. The shim filename is ABI-locked
|
|
40113
|
+
* (cpython-312), so a managed-python bump silently disables it — this probe
|
|
40114
|
+
* is what makes that failure loud instead of a garbled camera.
|
|
40115
|
+
*/
|
|
40116
|
+
probeGlancesCursesColours() {
|
|
40117
|
+
if (!this.glancesPythonPath) return;
|
|
40118
|
+
const probeEnv = {
|
|
40119
|
+
...process.env,
|
|
40120
|
+
...this.glancesEnv,
|
|
40121
|
+
TERM: "xterm-256color"
|
|
40122
|
+
};
|
|
40123
|
+
(0, node_child_process.execFile)(this.glancesPythonPath, ["-c", "import curses\ncurses.setupterm('xterm-256color')\nprint(curses.tigetnum('colors'))"], {
|
|
40124
|
+
env: probeEnv,
|
|
40125
|
+
timeout: 1e4
|
|
40126
|
+
}, (error, stdout) => {
|
|
40127
|
+
const colours = error ? NaN : Number.parseInt(stdout.trim(), 10);
|
|
40128
|
+
if (!Number.isFinite(colours) || colours <= 0) {
|
|
40129
|
+
this.ctx.logger.warn("glances curses reports no colours — terminal camera frames will render the B&W theme", { meta: {
|
|
40130
|
+
colours: Number.isFinite(colours) ? colours : null,
|
|
40131
|
+
shimApplied: this.glancesEnv !== void 0,
|
|
40132
|
+
error: error ? error instanceof Error ? error.message : String(error) : void 0
|
|
40133
|
+
} });
|
|
40134
|
+
return;
|
|
40135
|
+
}
|
|
40136
|
+
this.ctx.logger.debug("glances curses colours available", { meta: {
|
|
40137
|
+
colours,
|
|
40138
|
+
shimApplied: this.glancesEnv !== void 0
|
|
40139
|
+
} });
|
|
40140
|
+
});
|
|
40141
|
+
}
|
|
39546
40142
|
async onConfigChanged() {
|
|
39547
40143
|
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
39548
40144
|
this.manager?.reconfigureProfiles({
|
|
@@ -39556,6 +40152,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39556
40152
|
glancesPath: this.config.glancesPath,
|
|
39557
40153
|
glancesArgs: this.config.glancesArgs,
|
|
39558
40154
|
glancesPythonPath: this.glancesPythonPath,
|
|
40155
|
+
glancesEnv: this.glancesEnv,
|
|
39559
40156
|
allowShell: this.config.allowShell,
|
|
39560
40157
|
shellPath: this.config.shellPath,
|
|
39561
40158
|
maxSessions: this.config.maxSessions,
|
|
@@ -39659,37 +40256,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39659
40256
|
}
|
|
39660
40257
|
}
|
|
39661
40258
|
/**
|
|
39662
|
-
* A Terminal camera is a rendered screen.
|
|
39663
|
-
*
|
|
40259
|
+
* A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
|
|
40260
|
+
* forever, at full cost — see `silence-analysis.ts` for the measurements, the
|
|
40261
|
+
* reason `motion-detection` is in the list, and why a failure THROWS instead
|
|
40262
|
+
* of warning.
|
|
39664
40263
|
*
|
|
39665
|
-
*
|
|
39666
|
-
*
|
|
39667
|
-
*
|
|
39668
|
-
*
|
|
39669
|
-
*
|
|
39670
|
-
|
|
39671
|
-
|
|
39672
|
-
|
|
39673
|
-
|
|
39674
|
-
|
|
39675
|
-
|
|
39676
|
-
*/
|
|
39677
|
-
async silenceAnalysisFor(deviceId) {
|
|
39678
|
-
for (const switchId of ["object-detection", "audio-analysis"]) try {
|
|
39679
|
-
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
39680
|
-
deviceId,
|
|
39681
|
-
switchId,
|
|
39682
|
-
enabled: false
|
|
39683
|
-
});
|
|
39684
|
-
} catch (err) {
|
|
39685
|
-
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
39686
|
-
tags: { deviceId },
|
|
39687
|
-
meta: {
|
|
39688
|
-
switchId,
|
|
39689
|
-
error: err instanceof Error ? err.message : String(err)
|
|
39690
|
-
}
|
|
39691
|
-
});
|
|
39692
|
-
}
|
|
40264
|
+
* Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
|
|
40265
|
+
* the deprecated switch cap the previous version used (D113). Written ONLY on
|
|
40266
|
+
* creation: an operator who deliberately turns detection back on for a
|
|
40267
|
+
* terminal must win, and a reconcile that re-asserted every pass would
|
|
40268
|
+
* silently overrule them once a minute.
|
|
40269
|
+
*/
|
|
40270
|
+
silenceAnalysisFor(deviceId) {
|
|
40271
|
+
return silenceAnalysisFor({
|
|
40272
|
+
api: this.ctx.api,
|
|
40273
|
+
logger: this.ctx.logger
|
|
40274
|
+
}, deviceId);
|
|
39693
40275
|
}
|
|
39694
40276
|
terminalInstanceControl() {
|
|
39695
40277
|
return {
|
|
@@ -40002,11 +40584,17 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
40002
40584
|
}
|
|
40003
40585
|
};
|
|
40004
40586
|
//#endregion
|
|
40587
|
+
exports.TERMINAL_DEFAULT_BG = TERMINAL_DEFAULT_BG;
|
|
40588
|
+
exports.TERMINAL_DEFAULT_FG = TERMINAL_DEFAULT_FG;
|
|
40005
40589
|
exports.TerminalAddon = TerminalAddon;
|
|
40006
40590
|
exports.TerminalSessionManager = TerminalSessionManager;
|
|
40591
|
+
exports.buildCellRuns = buildCellRuns;
|
|
40007
40592
|
exports.buildProfiles = buildProfiles;
|
|
40008
40593
|
exports.createNodePtySpawner = createNodePtySpawner;
|
|
40009
40594
|
exports.createTerminalDataPlaneHandler = createTerminalDataPlaneHandler;
|
|
40010
40595
|
exports.createXtermScreen = createXtermScreen;
|
|
40011
40596
|
exports.findProfile = findProfile;
|
|
40597
|
+
exports.resolveCellStyle = resolveCellStyle;
|
|
40598
|
+
exports.terminalPaletteColor = terminalPaletteColor;
|
|
40599
|
+
exports.terminalRgbColor = terminalRgbColor;
|
|
40012
40600
|
exports.warmNodePty = warmNodePty;
|