@camstack/addon-terminal 0.1.12 → 0.1.15
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
|
|
@@ -18406,24 +18408,28 @@ var snapshotCapability = {
|
|
|
18406
18408
|
*
|
|
18407
18409
|
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18408
18410
|
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18409
|
-
* versions its image URL on that answer, and an image REQUEST
|
|
18410
|
-
*
|
|
18411
|
-
*
|
|
18412
|
-
*
|
|
18413
|
-
*
|
|
18414
|
-
*
|
|
18415
|
-
*
|
|
18416
|
-
*
|
|
18411
|
+
* versions its image URL on that answer, and an image REQUEST was the only
|
|
18412
|
+
* demand signal. Both of those are satisfiable by the client's own image
|
|
18413
|
+
* cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
|
|
18414
|
+
* in a previous session comes off disk with no network, no demand, and no
|
|
18415
|
+
* capture. Measured on the live hub: reopening after two minutes idle
|
|
18416
|
+
* painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
|
|
18417
|
+
* fleet only recovered because a later poll happened to observe a different
|
|
18418
|
+
* identity.
|
|
18417
18419
|
*
|
|
18418
18420
|
* ## The two properties that fix it
|
|
18419
18421
|
*
|
|
18420
18422
|
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18421
|
-
* always reaches the wrapper. This method therefore
|
|
18422
|
-
*
|
|
18423
|
-
*
|
|
18424
|
-
*
|
|
18425
|
-
*
|
|
18426
|
-
*
|
|
18423
|
+
* always reaches the wrapper. This method therefore CAPTURES, where
|
|
18424
|
+
* `getSnapshotOverview` must never (D93) — the distinction is not "one is
|
|
18425
|
+
* newer" but that the overview poll is app-wide (a capturing overview would
|
|
18426
|
+
* dial every camera on the install) while this is called by a rendered
|
|
18427
|
+
* surface naming the tiles it is actually painting, at the width it is
|
|
18428
|
+
* painting them.
|
|
18429
|
+
*
|
|
18430
|
+
* Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
|
|
18431
|
+
* server-side keep-warm loop was removed (operator directive — on-demand,
|
|
18432
|
+
* always), so a camera nobody is looking at costs nothing at all.
|
|
18427
18433
|
*
|
|
18428
18434
|
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18429
18435
|
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
@@ -27392,9 +27398,10 @@ var DeclaredDevices = class {
|
|
|
27392
27398
|
}
|
|
27393
27399
|
const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
|
|
27394
27400
|
const index = await this.readIndex();
|
|
27401
|
+
const live = await this.readLiveByStableId();
|
|
27395
27402
|
const outcomes = [];
|
|
27396
27403
|
for (const declaration of spec.devices) {
|
|
27397
|
-
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
27404
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
|
|
27398
27405
|
if (outcome !== null) outcomes.push(outcome);
|
|
27399
27406
|
}
|
|
27400
27407
|
return {
|
|
@@ -27440,6 +27447,26 @@ var DeclaredDevices = class {
|
|
|
27440
27447
|
return new Map(rows.map((row) => [row.stableId, row]));
|
|
27441
27448
|
}
|
|
27442
27449
|
/**
|
|
27450
|
+
* Devices this kernel already has CONSTRUCTED, by stableId.
|
|
27451
|
+
*
|
|
27452
|
+
* Distinct from {@link readIndex}, and the distinction is the bug: the index
|
|
27453
|
+
* is persisted rows, this is live objects. A row without an object must be
|
|
27454
|
+
* adopted; an object must be left exactly as it is.
|
|
27455
|
+
*
|
|
27456
|
+
* Failure is non-fatal and deliberately so — an empty map degrades to the
|
|
27457
|
+
* previous behaviour (attempt the adopt) rather than skipping a device that
|
|
27458
|
+
* genuinely needs bringing up.
|
|
27459
|
+
*/
|
|
27460
|
+
async readLiveByStableId() {
|
|
27461
|
+
try {
|
|
27462
|
+
const devices = await this.ports.devices.getAll();
|
|
27463
|
+
return new Map(devices.map((device) => [device.stableId, device]));
|
|
27464
|
+
} catch (err) {
|
|
27465
|
+
this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
27466
|
+
return /* @__PURE__ */ new Map();
|
|
27467
|
+
}
|
|
27468
|
+
}
|
|
27469
|
+
/**
|
|
27443
27470
|
* One declaration: adopt what exists, create what does not.
|
|
27444
27471
|
*
|
|
27445
27472
|
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
@@ -27448,8 +27475,15 @@ var DeclaredDevices = class {
|
|
|
27448
27475
|
* the declared name over the operator's rename. D49: that branch needs a
|
|
27449
27476
|
* second read to agree.
|
|
27450
27477
|
*/
|
|
27451
|
-
async applyDeclaration(declaration, integrationId, index) {
|
|
27478
|
+
async applyDeclaration(declaration, integrationId, index, live) {
|
|
27452
27479
|
try {
|
|
27480
|
+
const alreadyLive = live.get(declaration.stableId);
|
|
27481
|
+
if (alreadyLive !== void 0) return {
|
|
27482
|
+
stableId: declaration.stableId,
|
|
27483
|
+
deviceId: alreadyLive.id,
|
|
27484
|
+
device: alreadyLive,
|
|
27485
|
+
created: false
|
|
27486
|
+
};
|
|
27453
27487
|
let existing = index.get(declaration.stableId);
|
|
27454
27488
|
if (existing === void 0) {
|
|
27455
27489
|
existing = (await this.readIndex()).get(declaration.stableId);
|
|
@@ -33323,6 +33357,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
|
33323
33357
|
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
33324
33358
|
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
33325
33359
|
//#endregion
|
|
33360
|
+
//#region src/curses-shim.ts
|
|
33361
|
+
/**
|
|
33362
|
+
* macOS repair for the managed Python's curses.
|
|
33363
|
+
*
|
|
33364
|
+
* The python-build-standalone distribution the dependency resolver installs on
|
|
33365
|
+
* macOS links ncurses statically with the terminfo database compiled OUT
|
|
33366
|
+
* (`--disable-database`) and an ABI mismatch that misparses every numeric
|
|
33367
|
+
* capability of its five built-in fallback terminals (`colors=-1`,
|
|
33368
|
+
* `cols=524368`). `has_colors()` is therefore False under every TERM value and
|
|
33369
|
+
* `init_pair` is hard-blocked, so glances takes its B&W branch and paints the
|
|
33370
|
+
* whole screen with SGR conceal/reverse/alt-charset — the "text missing,
|
|
33371
|
+
* light bars instead" terminal camera frames. No environment variable can fix
|
|
33372
|
+
* it: the code that would read TERMINFO/TERMINFO_DIRS was never compiled in.
|
|
33373
|
+
*
|
|
33374
|
+
* The repair ships prebuilt `_curses` / `_curses_panel` extension modules
|
|
33375
|
+
* linked against the system `/usr/lib/libncurses.5.4.dylib` (universal2) and
|
|
33376
|
+
* puts their directory on PYTHONPATH at PTY spawn — PYTHONPATH precedes
|
|
33377
|
+
* lib-dynload on sys.path, so the working modules shadow the broken ones with
|
|
33378
|
+
* no change to the managed distribution. The filename is ABI-locked to
|
|
33379
|
+
* cpython-312: a managed-python bump to 3.13 makes the shim silently inert
|
|
33380
|
+
* (python ignores a mismatched ABI tag), which is why the addon probes
|
|
33381
|
+
* `tigetnum("colors")` at startup and logs loudly when colours are missing.
|
|
33382
|
+
*/
|
|
33383
|
+
/** `<addonRoot>/assets/curses-shim/py312`, valid from both src/ and dist/. */
|
|
33384
|
+
function cursesShimDir() {
|
|
33385
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "curses-shim", "py312");
|
|
33386
|
+
}
|
|
33387
|
+
function cursesShimDirExists(shimDir) {
|
|
33388
|
+
return existsSync(shimDir);
|
|
33389
|
+
}
|
|
33390
|
+
/**
|
|
33391
|
+
* The env to merge into the glances profile, or undefined when the shim does
|
|
33392
|
+
* not apply (non-macOS, or the assets were not shipped).
|
|
33393
|
+
*/
|
|
33394
|
+
function resolveGlancesCursesShimEnv(options) {
|
|
33395
|
+
if (options.platform !== "darwin" || !options.shimDirExists) return void 0;
|
|
33396
|
+
const existing = options.existingPythonPath?.trim();
|
|
33397
|
+
return { PYTHONPATH: existing ? `${options.shimDir}:${existing}` : options.shimDir };
|
|
33398
|
+
}
|
|
33399
|
+
//#endregion
|
|
33326
33400
|
//#region src/pty.ts
|
|
33327
33401
|
/**
|
|
33328
33402
|
* Minimal pty abstraction. The manager depends on this interface, never on
|
|
@@ -33453,14 +33527,253 @@ function buildTerminalInstanceCameraDeclarations(instances) {
|
|
|
33453
33527
|
function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
33454
33528
|
return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
|
|
33455
33529
|
}
|
|
33530
|
+
//#endregion
|
|
33531
|
+
//#region src/terminal-cell-runs.ts
|
|
33532
|
+
var TERMINAL_DEFAULT_FG = "#d7dce2";
|
|
33533
|
+
var TERMINAL_DEFAULT_BG = "#0b0d10";
|
|
33534
|
+
/**
|
|
33535
|
+
* The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
|
|
33536
|
+
* frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
|
|
33537
|
+
* whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
|
|
33538
|
+
* near-black background at 13px. Index 7 IS the default foreground, so plain
|
|
33539
|
+
* `CSI 37m` text renders identically to unstyled text.
|
|
33540
|
+
*/
|
|
33541
|
+
var TERMINAL_ANSI_PALETTE = [
|
|
33542
|
+
"#282c34",
|
|
33543
|
+
"#e06c75",
|
|
33544
|
+
"#98c379",
|
|
33545
|
+
"#e5c07b",
|
|
33546
|
+
"#61afef",
|
|
33547
|
+
"#c678dd",
|
|
33548
|
+
"#56b6c2",
|
|
33549
|
+
TERMINAL_DEFAULT_FG,
|
|
33550
|
+
"#5c6370",
|
|
33551
|
+
"#ef596f",
|
|
33552
|
+
"#89ca78",
|
|
33553
|
+
"#f0c674",
|
|
33554
|
+
"#6cb6ff",
|
|
33555
|
+
"#d55fde",
|
|
33556
|
+
"#2bbac5",
|
|
33557
|
+
"#ffffff"
|
|
33558
|
+
];
|
|
33559
|
+
/** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
|
|
33560
|
+
var TERMINAL_CUBE_LEVELS = [
|
|
33561
|
+
0,
|
|
33562
|
+
95,
|
|
33563
|
+
135,
|
|
33564
|
+
175,
|
|
33565
|
+
215,
|
|
33566
|
+
255
|
|
33567
|
+
];
|
|
33568
|
+
var TERMINAL_CUBE_FIRST = 16;
|
|
33569
|
+
var TERMINAL_GRAYSCALE_FIRST = 232;
|
|
33570
|
+
var TERMINAL_GRAYSCALE_BASE = 8;
|
|
33571
|
+
var TERMINAL_GRAYSCALE_STEP = 10;
|
|
33572
|
+
/** SGR 2 keeps the foreground legible; it must not become the background. */
|
|
33573
|
+
var TERMINAL_DIM_WEIGHT = .6;
|
|
33574
|
+
function channel(value) {
|
|
33575
|
+
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
33576
|
+
}
|
|
33577
|
+
function hex(red, green, blue) {
|
|
33578
|
+
return `#${channel(red)}${channel(green)}${channel(blue)}`;
|
|
33579
|
+
}
|
|
33580
|
+
function parseHex(color) {
|
|
33581
|
+
return [
|
|
33582
|
+
Number.parseInt(color.slice(1, 3), 16),
|
|
33583
|
+
Number.parseInt(color.slice(3, 5), 16),
|
|
33584
|
+
Number.parseInt(color.slice(5, 7), 16)
|
|
33585
|
+
];
|
|
33586
|
+
}
|
|
33587
|
+
/** Resolve an xterm palette index (0-255) to a hex colour. */
|
|
33588
|
+
function terminalPaletteColor(index) {
|
|
33589
|
+
const ansi = TERMINAL_ANSI_PALETTE[index];
|
|
33590
|
+
if (ansi !== void 0) return ansi;
|
|
33591
|
+
if (index >= TERMINAL_GRAYSCALE_FIRST) {
|
|
33592
|
+
const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
|
|
33593
|
+
return hex(level, level, level);
|
|
33594
|
+
}
|
|
33595
|
+
if (index >= TERMINAL_CUBE_FIRST) {
|
|
33596
|
+
const offset = index - TERMINAL_CUBE_FIRST;
|
|
33597
|
+
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);
|
|
33598
|
+
}
|
|
33599
|
+
return TERMINAL_DEFAULT_FG;
|
|
33600
|
+
}
|
|
33601
|
+
/** Resolve a 0xRRGGBB truecolor value to a hex colour. */
|
|
33602
|
+
function terminalRgbColor(value) {
|
|
33603
|
+
return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
|
|
33604
|
+
}
|
|
33605
|
+
function blend(color, toward, weight) {
|
|
33606
|
+
const [red, green, blue] = parseHex(color);
|
|
33607
|
+
const [targetRed, targetGreen, targetBlue] = parseHex(toward);
|
|
33608
|
+
return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
|
|
33609
|
+
}
|
|
33610
|
+
function resolveForeground(cell) {
|
|
33611
|
+
if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
|
|
33612
|
+
if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
|
|
33613
|
+
return TERMINAL_DEFAULT_FG;
|
|
33614
|
+
}
|
|
33615
|
+
function resolveBackground(cell) {
|
|
33616
|
+
if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
|
|
33617
|
+
if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
|
|
33618
|
+
return TERMINAL_DEFAULT_BG;
|
|
33619
|
+
}
|
|
33620
|
+
/**
|
|
33621
|
+
* Resolve one cell's attributes into concrete colours.
|
|
33622
|
+
*
|
|
33623
|
+
* Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
|
|
33624
|
+
* defaults is still a visible swap rather than a no-op — that is how a selected
|
|
33625
|
+
* or highlighted row in Glances reads. Invisible is then conceal-by-equality
|
|
33626
|
+
* (foreground painted in its own background): the cell keeps its columns, which
|
|
33627
|
+
* a dropped cell would not, and dropping it would shift the whole rest of the
|
|
33628
|
+
* row left.
|
|
33629
|
+
*/
|
|
33630
|
+
function resolveCellStyle(cell) {
|
|
33631
|
+
const inverse = cell.isInverse() !== 0;
|
|
33632
|
+
const plainFg = resolveForeground(cell);
|
|
33633
|
+
const plainBg = resolveBackground(cell);
|
|
33634
|
+
const background = inverse ? plainFg : plainBg;
|
|
33635
|
+
let foreground = inverse ? plainBg : plainFg;
|
|
33636
|
+
if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
|
|
33637
|
+
if (cell.isInvisible() !== 0) foreground = background;
|
|
33638
|
+
return {
|
|
33639
|
+
fg: foreground === "#d7dce2" ? null : foreground,
|
|
33640
|
+
bg: background === "#0b0d10" ? null : background,
|
|
33641
|
+
bold: cell.isBold() !== 0
|
|
33642
|
+
};
|
|
33643
|
+
}
|
|
33644
|
+
function sameStyle(left, right) {
|
|
33645
|
+
return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
|
|
33646
|
+
}
|
|
33647
|
+
/**
|
|
33648
|
+
* Merge adjacent same-style cells into runs, then drop the trailing run of
|
|
33649
|
+
* default-styled whitespace so a row costs what it draws — the same trim
|
|
33650
|
+
* `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
|
|
33651
|
+
* a green bar of spaces out to the right margin is a pixel Glances drew.
|
|
33652
|
+
*/
|
|
33653
|
+
function buildCellRuns(cells) {
|
|
33654
|
+
const runs = [];
|
|
33655
|
+
let text = "";
|
|
33656
|
+
let style = null;
|
|
33657
|
+
for (const cell of cells) {
|
|
33658
|
+
if (style !== null && sameStyle(style, cell.style)) {
|
|
33659
|
+
text += cell.text;
|
|
33660
|
+
continue;
|
|
33661
|
+
}
|
|
33662
|
+
if (style !== null) runs.push({
|
|
33663
|
+
text,
|
|
33664
|
+
...style
|
|
33665
|
+
});
|
|
33666
|
+
text = cell.text;
|
|
33667
|
+
style = cell.style;
|
|
33668
|
+
}
|
|
33669
|
+
if (style !== null) runs.push({
|
|
33670
|
+
text,
|
|
33671
|
+
...style
|
|
33672
|
+
});
|
|
33673
|
+
while (runs.length > 0) {
|
|
33674
|
+
const last = runs[runs.length - 1];
|
|
33675
|
+
if (last === void 0 || last.bg !== null) break;
|
|
33676
|
+
const trimmed = last.text.replace(/\s+$/u, "");
|
|
33677
|
+
if (trimmed === last.text) break;
|
|
33678
|
+
if (trimmed === "") {
|
|
33679
|
+
runs.pop();
|
|
33680
|
+
continue;
|
|
33681
|
+
}
|
|
33682
|
+
runs[runs.length - 1] = {
|
|
33683
|
+
...last,
|
|
33684
|
+
text: trimmed
|
|
33685
|
+
};
|
|
33686
|
+
break;
|
|
33687
|
+
}
|
|
33688
|
+
return runs;
|
|
33689
|
+
}
|
|
33690
|
+
/**
|
|
33691
|
+
* Monospace families to try, in order — NOT one family and a generic.
|
|
33692
|
+
*
|
|
33693
|
+
* A terminal screen is mostly box-drawing and block characters, and a font
|
|
33694
|
+
* without them renders the frame as noise rather than as missing detail.
|
|
33695
|
+
* `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
|
|
33696
|
+
* DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
|
|
33697
|
+
* coverage is not, and its Glances camera came out unreadable while the hub's
|
|
33698
|
+
* was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
|
|
33699
|
+
*
|
|
33700
|
+
* `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
|
|
33701
|
+
* present on every install, and derived from DejaVu Sans Mono — the same glyph
|
|
33702
|
+
* coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
|
|
33703
|
+
* generic stays last so a host with none of them still draws something.
|
|
33704
|
+
*/
|
|
33705
|
+
var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
|
|
33706
|
+
var TERMINAL_FONT_SIZE = 13;
|
|
33707
|
+
var TERMINAL_TEXT_MARGIN_X = 8;
|
|
33708
|
+
var TERMINAL_ROW_HEIGHT = 15;
|
|
33709
|
+
var TERMINAL_BASELINE_Y = 18;
|
|
33710
|
+
/**
|
|
33711
|
+
* Distance from a row's baseline up to the top of its cell box. Chosen so
|
|
33712
|
+
* consecutive rows tile exactly: row N's box runs from `baseline - this` for
|
|
33713
|
+
* `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
|
|
33714
|
+
* bar that stopped short would draw as stripes across a `CSI 42m` panel.
|
|
33715
|
+
*/
|
|
33716
|
+
var TERMINAL_CELL_ASCENT = 11.5;
|
|
33717
|
+
var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
|
|
33718
|
+
/** Two decimals is under a tenth of a pixel and keeps the SVG small. */
|
|
33719
|
+
function coordinate(value) {
|
|
33720
|
+
return String(Number(value.toFixed(2)));
|
|
33721
|
+
}
|
|
33456
33722
|
function escapeXml(value) {
|
|
33457
33723
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33458
33724
|
}
|
|
33459
|
-
/**
|
|
33460
|
-
|
|
33461
|
-
|
|
33462
|
-
|
|
33463
|
-
|
|
33725
|
+
/**
|
|
33726
|
+
* Render already-interpreted terminal rows into a compact MJPEG frame.
|
|
33727
|
+
*
|
|
33728
|
+
* `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
|
|
33729
|
+
* runs of whitespace by default, and a terminal's entire column alignment IS
|
|
33730
|
+
* runs of whitespace — Glances pads every field with spaces. Without it the
|
|
33731
|
+
* frame drew each line at roughly half its true width, crammed into the
|
|
33732
|
+
* top-left of a mostly-black image, while the SAME session over `attach`
|
|
33733
|
+
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33734
|
+
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33735
|
+
* collapsed against 178 px preserved.
|
|
33736
|
+
*
|
|
33737
|
+
* Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
|
|
33738
|
+
* never appended to the one before it, so the background rects and the glyphs
|
|
33739
|
+
* are placed off the same grid and cannot drift apart. `textLength` is emitted
|
|
33740
|
+
* with it because it is the correct declaration and renderers that honour it
|
|
33741
|
+
* get an exact grid — but it is not what makes this work: librsvg, which sharp
|
|
33742
|
+
* uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
|
|
33743
|
+
* 600 px still drew its natural 937 px). The anchoring is the guarantee.
|
|
33744
|
+
*/
|
|
33745
|
+
function renderTerminalSvg(rows) {
|
|
33746
|
+
const backgrounds = [];
|
|
33747
|
+
const texts = [];
|
|
33748
|
+
rows.slice(0, 40).forEach((row, index) => {
|
|
33749
|
+
const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
|
|
33750
|
+
const top = baseline - TERMINAL_CELL_ASCENT;
|
|
33751
|
+
let column = 0;
|
|
33752
|
+
for (const run of row) {
|
|
33753
|
+
if (column >= 120) break;
|
|
33754
|
+
const clipped = clipRun(run, 120 - column);
|
|
33755
|
+
const columns = [...clipped].length;
|
|
33756
|
+
if (columns === 0) continue;
|
|
33757
|
+
const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
|
|
33758
|
+
const width = columns * TERMINAL_CELL_WIDTH;
|
|
33759
|
+
if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
|
|
33760
|
+
if (clipped.trim() !== "") {
|
|
33761
|
+
const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
|
|
33762
|
+
const weight = run.bold ? " font-weight=\"bold\"" : "";
|
|
33763
|
+
texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
|
|
33764
|
+
}
|
|
33765
|
+
column += columns;
|
|
33766
|
+
}
|
|
33767
|
+
});
|
|
33768
|
+
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>`;
|
|
33769
|
+
}
|
|
33770
|
+
/** Cut a run to the columns still left in the row, by code point not unit. */
|
|
33771
|
+
function clipRun(run, remaining) {
|
|
33772
|
+
const points = [...run.text];
|
|
33773
|
+
return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
|
|
33774
|
+
}
|
|
33775
|
+
async function renderTerminalJpeg(rows) {
|
|
33776
|
+
return sharp(Buffer.from(renderTerminalSvg(rows))).jpeg({
|
|
33464
33777
|
quality: 82,
|
|
33465
33778
|
chromaSubsampling: "4:2:0"
|
|
33466
33779
|
}).toBuffer();
|
|
@@ -38366,6 +38679,40 @@ function createXtermScreen(cols, rows) {
|
|
|
38366
38679
|
for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
|
|
38367
38680
|
return lines;
|
|
38368
38681
|
},
|
|
38682
|
+
/**
|
|
38683
|
+
* The colour-bearing read. `translateToString` — the only cell read this
|
|
38684
|
+
* screen used to offer — keeps characters and discards every attribute,
|
|
38685
|
+
* which is why the terminal camera rendered a fully-coloured Glances in
|
|
38686
|
+
* grey. Walking cells is the only way to get the attributes back.
|
|
38687
|
+
*
|
|
38688
|
+
* A zero-width cell is the tail half of a wide glyph (CJK, some emoji);
|
|
38689
|
+
* its characters already came back with the leading cell, so skipping it
|
|
38690
|
+
* keeps the text right. `getNullCell()` is reused across the whole screen
|
|
38691
|
+
* because `getCell` allocates otherwise — 4800 objects per frame.
|
|
38692
|
+
*/
|
|
38693
|
+
styledLines: () => {
|
|
38694
|
+
const buffer = term.buffer.active;
|
|
38695
|
+
const scratch = buffer.getNullCell();
|
|
38696
|
+
const styled = [];
|
|
38697
|
+
for (let row = 0; row < term.rows; row += 1) {
|
|
38698
|
+
const line = buffer.getLine(buffer.viewportY + row);
|
|
38699
|
+
if (!line) {
|
|
38700
|
+
styled.push([]);
|
|
38701
|
+
continue;
|
|
38702
|
+
}
|
|
38703
|
+
const cells = [];
|
|
38704
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
38705
|
+
const cell = line.getCell(column, scratch);
|
|
38706
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
38707
|
+
cells.push({
|
|
38708
|
+
text: cell.getChars() || " ",
|
|
38709
|
+
style: resolveCellStyle(cell)
|
|
38710
|
+
});
|
|
38711
|
+
}
|
|
38712
|
+
styled.push(buildCellRuns(cells));
|
|
38713
|
+
}
|
|
38714
|
+
return styled;
|
|
38715
|
+
},
|
|
38369
38716
|
dispose: () => {
|
|
38370
38717
|
term.dispose();
|
|
38371
38718
|
}
|
|
@@ -38376,6 +38723,9 @@ function createXtermScreen(cols, rows) {
|
|
|
38376
38723
|
var MJPEG_BOUNDARY = "camstack-terminal-frame";
|
|
38377
38724
|
var SESSION_IDLE_MS = 3e4;
|
|
38378
38725
|
var SNAPSHOT_STARTUP_WAIT_MS = 1500;
|
|
38726
|
+
var SNAPSHOT_SETTLE_QUIET_MS = 300;
|
|
38727
|
+
var SNAPSHOT_SETTLE_BUDGET_MS = 3e3;
|
|
38728
|
+
var SNAPSHOT_SETTLE_MAX_PULLS = 20;
|
|
38379
38729
|
var CLOSE_RETRY_BASE_MS = 50;
|
|
38380
38730
|
var CLOSE_RETRY_MAX_MS = 1e3;
|
|
38381
38731
|
var CLOSE_ATTEMPTS_PER_PASS = 3;
|
|
@@ -38500,20 +38850,36 @@ var TerminalCameraRelay = class {
|
|
|
38500
38850
|
if (state.openPromise === opening) state.openPromise = null;
|
|
38501
38851
|
}
|
|
38502
38852
|
}
|
|
38853
|
+
/** Replay one output batch into the relay screen; true = the session exited. */
|
|
38854
|
+
applyBatch(state, batch) {
|
|
38855
|
+
if (batch.reset) {
|
|
38856
|
+
state.screen.dispose();
|
|
38857
|
+
state.screen = createXtermScreen(120, 40);
|
|
38858
|
+
if (batch.snapshot) state.screen.write(batch.snapshot);
|
|
38859
|
+
}
|
|
38860
|
+
let exited = false;
|
|
38861
|
+
for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
|
|
38862
|
+
else {
|
|
38863
|
+
state.sessionId = null;
|
|
38864
|
+
exited = true;
|
|
38865
|
+
}
|
|
38866
|
+
state.cursor = exited ? 0 : batch.cursor;
|
|
38867
|
+
return exited;
|
|
38868
|
+
}
|
|
38503
38869
|
async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
|
|
38504
38870
|
if (state.framePromise) {
|
|
38505
38871
|
await state.framePromise;
|
|
38506
38872
|
return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
|
|
38507
38873
|
}
|
|
38508
38874
|
const render = async () => {
|
|
38509
|
-
const
|
|
38875
|
+
const initialPull = state.sessionId === null && waitForInitialOutput;
|
|
38510
38876
|
const sessionId = await this.ensureSession(state);
|
|
38511
38877
|
let batch;
|
|
38512
38878
|
try {
|
|
38513
38879
|
batch = await this.api.pullOutput(state.nodeId, {
|
|
38514
38880
|
sessionId,
|
|
38515
38881
|
afterSeq: state.cursor,
|
|
38516
|
-
...
|
|
38882
|
+
...initialPull ? {
|
|
38517
38883
|
waitMs: SNAPSHOT_STARTUP_WAIT_MS,
|
|
38518
38884
|
waitForOutput: true
|
|
38519
38885
|
} : {}
|
|
@@ -38535,21 +38901,29 @@ var TerminalCameraRelay = class {
|
|
|
38535
38901
|
state.cursor = 0;
|
|
38536
38902
|
throw error;
|
|
38537
38903
|
}
|
|
38538
|
-
|
|
38539
|
-
|
|
38540
|
-
|
|
38541
|
-
|
|
38542
|
-
|
|
38543
|
-
|
|
38544
|
-
|
|
38545
|
-
|
|
38546
|
-
|
|
38547
|
-
|
|
38904
|
+
let exited = this.applyBatch(state, batch);
|
|
38905
|
+
if (initialPull && !exited) {
|
|
38906
|
+
const deadline = Date.now() + SNAPSHOT_SETTLE_BUDGET_MS;
|
|
38907
|
+
for (let pulls = 0; pulls < SNAPSHOT_SETTLE_MAX_PULLS && Date.now() < deadline; pulls += 1) {
|
|
38908
|
+
let settle;
|
|
38909
|
+
try {
|
|
38910
|
+
settle = await this.api.pullOutput(state.nodeId, {
|
|
38911
|
+
sessionId,
|
|
38912
|
+
afterSeq: state.cursor,
|
|
38913
|
+
waitMs: SNAPSHOT_SETTLE_QUIET_MS,
|
|
38914
|
+
waitForOutput: true
|
|
38915
|
+
});
|
|
38916
|
+
} catch {
|
|
38917
|
+
break;
|
|
38918
|
+
}
|
|
38919
|
+
const active = settle.reset || settle.events.some((event) => event.kind === "data");
|
|
38920
|
+
exited = this.applyBatch(state, settle);
|
|
38921
|
+
if (exited || !active) break;
|
|
38922
|
+
}
|
|
38548
38923
|
}
|
|
38549
|
-
state.cursor = exited ? 0 : batch.cursor;
|
|
38550
38924
|
await state.screen.flush();
|
|
38551
38925
|
if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
|
|
38552
|
-
state.jpeg = await renderTerminalJpeg(state.screen.
|
|
38926
|
+
state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
|
|
38553
38927
|
state.renderedCursor = state.cursor;
|
|
38554
38928
|
}
|
|
38555
38929
|
return state.jpeg;
|
|
@@ -38998,7 +39372,8 @@ function buildProfiles(options) {
|
|
|
38998
39372
|
"-m",
|
|
38999
39373
|
"glances",
|
|
39000
39374
|
...options.glancesArgs ?? []
|
|
39001
|
-
]
|
|
39375
|
+
],
|
|
39376
|
+
...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
|
|
39002
39377
|
});
|
|
39003
39378
|
}
|
|
39004
39379
|
if (options.allowShell) profiles.push({
|
|
@@ -39083,6 +39458,7 @@ var TerminalSessionManager = class {
|
|
|
39083
39458
|
glancesPath: opts.glancesPath,
|
|
39084
39459
|
glancesArgs: opts.glancesArgs,
|
|
39085
39460
|
glancesPythonPath: opts.glancesPythonPath,
|
|
39461
|
+
glancesEnv: opts.glancesEnv,
|
|
39086
39462
|
allowShell: opts.allowShell,
|
|
39087
39463
|
shellPath: opts.shellPath,
|
|
39088
39464
|
customProfiles: opts.customProfiles,
|
|
@@ -39380,6 +39756,18 @@ var TerminalSessionManager = class {
|
|
|
39380
39756
|
};
|
|
39381
39757
|
//#endregion
|
|
39382
39758
|
//#region src/addon.ts
|
|
39759
|
+
/**
|
|
39760
|
+
* Terminal addon — hosts interactive pty sessions running an allowlisted
|
|
39761
|
+
* profile (`monitor` → `btm`), streamed to the Admin UI over the data plane.
|
|
39762
|
+
*
|
|
39763
|
+
* Phase 1 of docs/design/2026-07-26-terminal-session-design.md: a real TTY good
|
|
39764
|
+
* enough to run a full-screen program. Phase 2 (streaming a session as a camera)
|
|
39765
|
+
* is a separate device-provider concern and does not change this addon's cap.
|
|
39766
|
+
*
|
|
39767
|
+
* Its own package (not a builtin) because it carries a native dependency
|
|
39768
|
+
* (`node-pty`) and should update via `camstack deploy` independently of the
|
|
39769
|
+
* framework, exactly like the node-av decoder addon.
|
|
39770
|
+
*/
|
|
39383
39771
|
var DEFAULTS = {
|
|
39384
39772
|
btmPath: "",
|
|
39385
39773
|
btmEnabled: true,
|
|
@@ -39412,12 +39800,21 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39412
39800
|
instanceMutationQueue = new TerminalInstanceMutationQueue();
|
|
39413
39801
|
terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
|
|
39414
39802
|
glancesPythonPath = "";
|
|
39803
|
+
glancesEnv;
|
|
39415
39804
|
constructor() {
|
|
39416
39805
|
super({ ...DEFAULTS });
|
|
39417
39806
|
}
|
|
39418
39807
|
async onInitialize() {
|
|
39419
39808
|
await warmNodePty();
|
|
39420
39809
|
this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
|
|
39810
|
+
const shimDir = cursesShimDir();
|
|
39811
|
+
this.glancesEnv = resolveGlancesCursesShimEnv({
|
|
39812
|
+
platform: process.platform,
|
|
39813
|
+
shimDir,
|
|
39814
|
+
shimDirExists: cursesShimDirExists(shimDir),
|
|
39815
|
+
existingPythonPath: process.env["PYTHONPATH"]
|
|
39816
|
+
});
|
|
39817
|
+
this.probeGlancesCursesColours();
|
|
39421
39818
|
const manager = new TerminalSessionManager({
|
|
39422
39819
|
spawn: createNodePtySpawner(),
|
|
39423
39820
|
screenFactory: createXtermScreen,
|
|
@@ -39433,6 +39830,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39433
39830
|
glancesPath: this.config.glancesPath,
|
|
39434
39831
|
glancesArgs: this.config.glancesArgs,
|
|
39435
39832
|
glancesPythonPath: this.glancesPythonPath,
|
|
39833
|
+
glancesEnv: this.glancesEnv,
|
|
39436
39834
|
allowShell: this.config.allowShell,
|
|
39437
39835
|
shellPath: this.config.shellPath,
|
|
39438
39836
|
maxSessions: this.config.maxSessions,
|
|
@@ -39478,6 +39876,39 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39478
39876
|
provider: manager
|
|
39479
39877
|
}];
|
|
39480
39878
|
}
|
|
39879
|
+
/**
|
|
39880
|
+
* Fire-and-forget canary: the managed python must see a colour-capable
|
|
39881
|
+
* curses, or glances paints its B&W theme (SGR conceal/reverse) and the
|
|
39882
|
+
* terminal camera frames lose their text. The shim filename is ABI-locked
|
|
39883
|
+
* (cpython-312), so a managed-python bump silently disables it — this probe
|
|
39884
|
+
* is what makes that failure loud instead of a garbled camera.
|
|
39885
|
+
*/
|
|
39886
|
+
probeGlancesCursesColours() {
|
|
39887
|
+
if (!this.glancesPythonPath) return;
|
|
39888
|
+
const probeEnv = {
|
|
39889
|
+
...process.env,
|
|
39890
|
+
...this.glancesEnv,
|
|
39891
|
+
TERM: "xterm-256color"
|
|
39892
|
+
};
|
|
39893
|
+
execFile(this.glancesPythonPath, ["-c", "import curses\ncurses.setupterm('xterm-256color')\nprint(curses.tigetnum('colors'))"], {
|
|
39894
|
+
env: probeEnv,
|
|
39895
|
+
timeout: 1e4
|
|
39896
|
+
}, (error, stdout) => {
|
|
39897
|
+
const colours = error ? NaN : Number.parseInt(stdout.trim(), 10);
|
|
39898
|
+
if (!Number.isFinite(colours) || colours <= 0) {
|
|
39899
|
+
this.ctx.logger.warn("glances curses reports no colours — terminal camera frames will render the B&W theme", { meta: {
|
|
39900
|
+
colours: Number.isFinite(colours) ? colours : null,
|
|
39901
|
+
shimApplied: this.glancesEnv !== void 0,
|
|
39902
|
+
error: error ? error instanceof Error ? error.message : String(error) : void 0
|
|
39903
|
+
} });
|
|
39904
|
+
return;
|
|
39905
|
+
}
|
|
39906
|
+
this.ctx.logger.debug("glances curses colours available", { meta: {
|
|
39907
|
+
colours,
|
|
39908
|
+
shimApplied: this.glancesEnv !== void 0
|
|
39909
|
+
} });
|
|
39910
|
+
});
|
|
39911
|
+
}
|
|
39481
39912
|
async onConfigChanged() {
|
|
39482
39913
|
this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
|
|
39483
39914
|
this.manager?.reconfigureProfiles({
|
|
@@ -39491,6 +39922,7 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39491
39922
|
glancesPath: this.config.glancesPath,
|
|
39492
39923
|
glancesArgs: this.config.glancesArgs,
|
|
39493
39924
|
glancesPythonPath: this.glancesPythonPath,
|
|
39925
|
+
glancesEnv: this.glancesEnv,
|
|
39494
39926
|
allowShell: this.config.allowShell,
|
|
39495
39927
|
shellPath: this.config.shellPath,
|
|
39496
39928
|
maxSessions: this.config.maxSessions,
|
|
@@ -39586,12 +40018,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39586
40018
|
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39587
40019
|
}
|
|
39588
40020
|
}
|
|
40021
|
+
if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
|
|
39589
40022
|
const nodeId = outcome.device.config.get("nodeId");
|
|
39590
40023
|
const profileId = outcome.device.config.get("profileId");
|
|
39591
40024
|
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39592
40025
|
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
39593
40026
|
}
|
|
39594
40027
|
}
|
|
40028
|
+
/**
|
|
40029
|
+
* A Terminal camera is a rendered screen. Object detection on it finds
|
|
40030
|
+
* nothing, forever, at full cost.
|
|
40031
|
+
*
|
|
40032
|
+
* Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
|
|
40033
|
+
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
40034
|
+
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
40035
|
+
* one terminal per node and it is a standing tax on a hub that was already
|
|
40036
|
+
* shedding 86 % of its capture queue.
|
|
40037
|
+
*
|
|
40038
|
+
* Written through `setCameraSwitch`, which is the authority that already owns
|
|
40039
|
+
* this function — [D62] forbids a second store that disagrees with it. And
|
|
40040
|
+
* written ONLY on creation: an operator who deliberately turns detection back
|
|
40041
|
+
* on for a terminal must win, and a reconcile that re-asserted every pass
|
|
40042
|
+
* would silently overrule them once a minute.
|
|
40043
|
+
*/
|
|
40044
|
+
async silenceAnalysisFor(deviceId) {
|
|
40045
|
+
for (const switchId of ["object-detection", "audio-analysis"]) try {
|
|
40046
|
+
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
40047
|
+
deviceId,
|
|
40048
|
+
switchId,
|
|
40049
|
+
enabled: false
|
|
40050
|
+
});
|
|
40051
|
+
} catch (err) {
|
|
40052
|
+
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
40053
|
+
tags: { deviceId },
|
|
40054
|
+
meta: {
|
|
40055
|
+
switchId,
|
|
40056
|
+
error: err instanceof Error ? err.message : String(err)
|
|
40057
|
+
}
|
|
40058
|
+
});
|
|
40059
|
+
}
|
|
40060
|
+
}
|
|
39595
40061
|
terminalInstanceControl() {
|
|
39596
40062
|
return {
|
|
39597
40063
|
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|
|
@@ -39903,4 +40369,4 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39903
40369
|
}
|
|
39904
40370
|
};
|
|
39905
40371
|
//#endregion
|
|
39906
|
-
export { TerminalAddon, createXtermScreen as a, createTerminalDataPlaneHandler as i, buildProfiles as n,
|
|
40372
|
+
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 };
|