@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.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");
@@ -18428,24 +18431,28 @@ var snapshotCapability = {
18428
18431
  *
18429
18432
  * `getSnapshotOverview` is cache-only by contract: it answers from whatever
18430
18433
  * the wrapper happens to hold and never captures. Under D93 the client
18431
- * versions its image URL on that answer, and an image REQUEST is what enrols
18432
- * a camera in the keep-warm loop. Both of those are satisfiable by the
18433
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
18434
- * — so a URL painted in a previous session comes off disk with no network,
18435
- * no enrolment, and nothing warming. Measured on the live hub: reopening
18436
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
18437
- * HTTP requests, and the fleet only recovered because a later poll happened
18438
- * to observe a different identity.
18434
+ * versions its image URL on that answer, and an image REQUEST was the only
18435
+ * demand signal. Both of those are satisfiable by the client's own image
18436
+ * cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
18437
+ * in a previous session comes off disk with no network, no demand, and no
18438
+ * capture. Measured on the live hub: reopening after two minutes idle
18439
+ * painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
18440
+ * fleet only recovered because a later poll happened to observe a different
18441
+ * identity.
18439
18442
  *
18440
18443
  * ## The two properties that fix it
18441
18444
  *
18442
18445
  * **It is an RPC, so no client cache can answer it.** The demand signal
18443
- * always reaches the wrapper. This method therefore MAY create keep-warm
18444
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
18445
- * distinction is not "one is newer" but that the overview poll is app-wide
18446
- * (a creating overview would warm every camera on the install) while this is
18447
- * called by a rendered surface naming the tiles it is actually painting, at
18448
- * the width it is painting them.
18446
+ * always reaches the wrapper. This method therefore CAPTURES, where
18447
+ * `getSnapshotOverview` must never (D93) — the distinction is not "one is
18448
+ * newer" but that the overview poll is app-wide (a capturing overview would
18449
+ * dial every camera on the install) while this is called by a rendered
18450
+ * surface naming the tiles it is actually painting, at the width it is
18451
+ * painting them.
18452
+ *
18453
+ * Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
18454
+ * server-side keep-warm loop was removed (operator directive — on-demand,
18455
+ * always), so a camera nobody is looking at costs nothing at all.
18449
18456
  *
18450
18457
  * **It waits, briefly and boundedly, for the capture it triggered.** The
18451
18458
  * returned `capturedAt` is the frame the link will serve, not the frame the
@@ -27414,9 +27421,10 @@ var DeclaredDevices = class {
27414
27421
  }
27415
27422
  const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
27416
27423
  const index = await this.readIndex();
27424
+ const live = await this.readLiveByStableId();
27417
27425
  const outcomes = [];
27418
27426
  for (const declaration of spec.devices) {
27419
- const outcome = await this.applyDeclaration(declaration, integrationId, index);
27427
+ const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
27420
27428
  if (outcome !== null) outcomes.push(outcome);
27421
27429
  }
27422
27430
  return {
@@ -27462,6 +27470,26 @@ var DeclaredDevices = class {
27462
27470
  return new Map(rows.map((row) => [row.stableId, row]));
27463
27471
  }
27464
27472
  /**
27473
+ * Devices this kernel already has CONSTRUCTED, by stableId.
27474
+ *
27475
+ * Distinct from {@link readIndex}, and the distinction is the bug: the index
27476
+ * is persisted rows, this is live objects. A row without an object must be
27477
+ * adopted; an object must be left exactly as it is.
27478
+ *
27479
+ * Failure is non-fatal and deliberately so — an empty map degrades to the
27480
+ * previous behaviour (attempt the adopt) rather than skipping a device that
27481
+ * genuinely needs bringing up.
27482
+ */
27483
+ async readLiveByStableId() {
27484
+ try {
27485
+ const devices = await this.ports.devices.getAll();
27486
+ return new Map(devices.map((device) => [device.stableId, device]));
27487
+ } catch (err) {
27488
+ this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
27489
+ return /* @__PURE__ */ new Map();
27490
+ }
27491
+ }
27492
+ /**
27465
27493
  * One declaration: adopt what exists, create what does not.
27466
27494
  *
27467
27495
  * The create branch is the destructive one — it seeds `initialMeta`, and
@@ -27470,8 +27498,15 @@ var DeclaredDevices = class {
27470
27498
  * the declared name over the operator's rename. D49: that branch needs a
27471
27499
  * second read to agree.
27472
27500
  */
27473
- async applyDeclaration(declaration, integrationId, index) {
27501
+ async applyDeclaration(declaration, integrationId, index, live) {
27474
27502
  try {
27503
+ const alreadyLive = live.get(declaration.stableId);
27504
+ if (alreadyLive !== void 0) return {
27505
+ stableId: declaration.stableId,
27506
+ deviceId: alreadyLive.id,
27507
+ device: alreadyLive,
27508
+ created: false
27509
+ };
27475
27510
  let existing = index.get(declaration.stableId);
27476
27511
  if (existing === void 0) {
27477
27512
  existing = (await this.readIndex()).get(declaration.stableId);
@@ -33345,6 +33380,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33345
33380
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33346
33381
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33347
33382
  //#endregion
33383
+ //#region src/curses-shim.ts
33384
+ /**
33385
+ * macOS repair for the managed Python's curses.
33386
+ *
33387
+ * The python-build-standalone distribution the dependency resolver installs on
33388
+ * macOS links ncurses statically with the terminfo database compiled OUT
33389
+ * (`--disable-database`) and an ABI mismatch that misparses every numeric
33390
+ * capability of its five built-in fallback terminals (`colors=-1`,
33391
+ * `cols=524368`). `has_colors()` is therefore False under every TERM value and
33392
+ * `init_pair` is hard-blocked, so glances takes its B&W branch and paints the
33393
+ * whole screen with SGR conceal/reverse/alt-charset — the "text missing,
33394
+ * light bars instead" terminal camera frames. No environment variable can fix
33395
+ * it: the code that would read TERMINFO/TERMINFO_DIRS was never compiled in.
33396
+ *
33397
+ * The repair ships prebuilt `_curses` / `_curses_panel` extension modules
33398
+ * linked against the system `/usr/lib/libncurses.5.4.dylib` (universal2) and
33399
+ * puts their directory on PYTHONPATH at PTY spawn — PYTHONPATH precedes
33400
+ * lib-dynload on sys.path, so the working modules shadow the broken ones with
33401
+ * no change to the managed distribution. The filename is ABI-locked to
33402
+ * cpython-312: a managed-python bump to 3.13 makes the shim silently inert
33403
+ * (python ignores a mismatched ABI tag), which is why the addon probes
33404
+ * `tigetnum("colors")` at startup and logs loudly when colours are missing.
33405
+ */
33406
+ /** `<addonRoot>/assets/curses-shim/py312`, valid from both src/ and dist/. */
33407
+ function cursesShimDir() {
33408
+ return node_path.default.join(node_path.default.dirname((0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href)), "..", "assets", "curses-shim", "py312");
33409
+ }
33410
+ function cursesShimDirExists(shimDir) {
33411
+ return (0, node_fs.existsSync)(shimDir);
33412
+ }
33413
+ /**
33414
+ * The env to merge into the glances profile, or undefined when the shim does
33415
+ * not apply (non-macOS, or the assets were not shipped).
33416
+ */
33417
+ function resolveGlancesCursesShimEnv(options) {
33418
+ if (options.platform !== "darwin" || !options.shimDirExists) return void 0;
33419
+ const existing = options.existingPythonPath?.trim();
33420
+ return { PYTHONPATH: existing ? `${options.shimDir}:${existing}` : options.shimDir };
33421
+ }
33422
+ //#endregion
33348
33423
  //#region src/pty.ts
33349
33424
  /**
33350
33425
  * Minimal pty abstraction. The manager depends on this interface, never on
@@ -33475,14 +33550,253 @@ function buildTerminalInstanceCameraDeclarations(instances) {
33475
33550
  function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33476
33551
  return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33477
33552
  }
33553
+ //#endregion
33554
+ //#region src/terminal-cell-runs.ts
33555
+ var TERMINAL_DEFAULT_FG = "#d7dce2";
33556
+ var TERMINAL_DEFAULT_BG = "#0b0d10";
33557
+ /**
33558
+ * The 16 ANSI colours, in a dark-theme variant chosen to sit with the existing
33559
+ * frame (`#0b0d10` ground, `#d7dce2` text) rather than the raw xterm defaults,
33560
+ * whose pure `#0000ff` blue and `#008000` green are close to unreadable on a
33561
+ * near-black background at 13px. Index 7 IS the default foreground, so plain
33562
+ * `CSI 37m` text renders identically to unstyled text.
33563
+ */
33564
+ var TERMINAL_ANSI_PALETTE = [
33565
+ "#282c34",
33566
+ "#e06c75",
33567
+ "#98c379",
33568
+ "#e5c07b",
33569
+ "#61afef",
33570
+ "#c678dd",
33571
+ "#56b6c2",
33572
+ TERMINAL_DEFAULT_FG,
33573
+ "#5c6370",
33574
+ "#ef596f",
33575
+ "#89ca78",
33576
+ "#f0c674",
33577
+ "#6cb6ff",
33578
+ "#d55fde",
33579
+ "#2bbac5",
33580
+ "#ffffff"
33581
+ ];
33582
+ /** The six levels of the xterm 6×6×6 colour cube (indices 16-231). */
33583
+ var TERMINAL_CUBE_LEVELS = [
33584
+ 0,
33585
+ 95,
33586
+ 135,
33587
+ 175,
33588
+ 215,
33589
+ 255
33590
+ ];
33591
+ var TERMINAL_CUBE_FIRST = 16;
33592
+ var TERMINAL_GRAYSCALE_FIRST = 232;
33593
+ var TERMINAL_GRAYSCALE_BASE = 8;
33594
+ var TERMINAL_GRAYSCALE_STEP = 10;
33595
+ /** SGR 2 keeps the foreground legible; it must not become the background. */
33596
+ var TERMINAL_DIM_WEIGHT = .6;
33597
+ function channel(value) {
33598
+ return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
33599
+ }
33600
+ function hex(red, green, blue) {
33601
+ return `#${channel(red)}${channel(green)}${channel(blue)}`;
33602
+ }
33603
+ function parseHex(color) {
33604
+ return [
33605
+ Number.parseInt(color.slice(1, 3), 16),
33606
+ Number.parseInt(color.slice(3, 5), 16),
33607
+ Number.parseInt(color.slice(5, 7), 16)
33608
+ ];
33609
+ }
33610
+ /** Resolve an xterm palette index (0-255) to a hex colour. */
33611
+ function terminalPaletteColor(index) {
33612
+ const ansi = TERMINAL_ANSI_PALETTE[index];
33613
+ if (ansi !== void 0) return ansi;
33614
+ if (index >= TERMINAL_GRAYSCALE_FIRST) {
33615
+ const level = TERMINAL_GRAYSCALE_BASE + (index - TERMINAL_GRAYSCALE_FIRST) * TERMINAL_GRAYSCALE_STEP;
33616
+ return hex(level, level, level);
33617
+ }
33618
+ if (index >= TERMINAL_CUBE_FIRST) {
33619
+ const offset = index - TERMINAL_CUBE_FIRST;
33620
+ 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);
33621
+ }
33622
+ return TERMINAL_DEFAULT_FG;
33623
+ }
33624
+ /** Resolve a 0xRRGGBB truecolor value to a hex colour. */
33625
+ function terminalRgbColor(value) {
33626
+ return hex(value >> 16 & 255, value >> 8 & 255, value & 255);
33627
+ }
33628
+ function blend(color, toward, weight) {
33629
+ const [red, green, blue] = parseHex(color);
33630
+ const [targetRed, targetGreen, targetBlue] = parseHex(toward);
33631
+ return hex(red * weight + targetRed * (1 - weight), green * weight + targetGreen * (1 - weight), blue * weight + targetBlue * (1 - weight));
33632
+ }
33633
+ function resolveForeground(cell) {
33634
+ if (cell.isFgRGB()) return terminalRgbColor(cell.getFgColor());
33635
+ if (cell.isFgPalette()) return terminalPaletteColor(cell.getFgColor());
33636
+ return TERMINAL_DEFAULT_FG;
33637
+ }
33638
+ function resolveBackground(cell) {
33639
+ if (cell.isBgRGB()) return terminalRgbColor(cell.getBgColor());
33640
+ if (cell.isBgPalette()) return terminalPaletteColor(cell.getBgColor());
33641
+ return TERMINAL_DEFAULT_BG;
33642
+ }
33643
+ /**
33644
+ * Resolve one cell's attributes into concrete colours.
33645
+ *
33646
+ * Inverse is applied by SWAPPING the already-resolved pair, so inverse over two
33647
+ * defaults is still a visible swap rather than a no-op — that is how a selected
33648
+ * or highlighted row in Glances reads. Invisible is then conceal-by-equality
33649
+ * (foreground painted in its own background): the cell keeps its columns, which
33650
+ * a dropped cell would not, and dropping it would shift the whole rest of the
33651
+ * row left.
33652
+ */
33653
+ function resolveCellStyle(cell) {
33654
+ const inverse = cell.isInverse() !== 0;
33655
+ const plainFg = resolveForeground(cell);
33656
+ const plainBg = resolveBackground(cell);
33657
+ const background = inverse ? plainFg : plainBg;
33658
+ let foreground = inverse ? plainBg : plainFg;
33659
+ if (cell.isDim() !== 0) foreground = blend(foreground, background, TERMINAL_DIM_WEIGHT);
33660
+ if (cell.isInvisible() !== 0) foreground = background;
33661
+ return {
33662
+ fg: foreground === "#d7dce2" ? null : foreground,
33663
+ bg: background === "#0b0d10" ? null : background,
33664
+ bold: cell.isBold() !== 0
33665
+ };
33666
+ }
33667
+ function sameStyle(left, right) {
33668
+ return left.fg === right.fg && left.bg === right.bg && left.bold === right.bold;
33669
+ }
33670
+ /**
33671
+ * Merge adjacent same-style cells into runs, then drop the trailing run of
33672
+ * default-styled whitespace so a row costs what it draws — the same trim
33673
+ * `translateToString(true)` performs. Whitespace carrying a BACKGROUND is kept:
33674
+ * a green bar of spaces out to the right margin is a pixel Glances drew.
33675
+ */
33676
+ function buildCellRuns(cells) {
33677
+ const runs = [];
33678
+ let text = "";
33679
+ let style = null;
33680
+ for (const cell of cells) {
33681
+ if (style !== null && sameStyle(style, cell.style)) {
33682
+ text += cell.text;
33683
+ continue;
33684
+ }
33685
+ if (style !== null) runs.push({
33686
+ text,
33687
+ ...style
33688
+ });
33689
+ text = cell.text;
33690
+ style = cell.style;
33691
+ }
33692
+ if (style !== null) runs.push({
33693
+ text,
33694
+ ...style
33695
+ });
33696
+ while (runs.length > 0) {
33697
+ const last = runs[runs.length - 1];
33698
+ if (last === void 0 || last.bg !== null) break;
33699
+ const trimmed = last.text.replace(/\s+$/u, "");
33700
+ if (trimmed === last.text) break;
33701
+ if (trimmed === "") {
33702
+ runs.pop();
33703
+ continue;
33704
+ }
33705
+ runs[runs.length - 1] = {
33706
+ ...last,
33707
+ text: trimmed
33708
+ };
33709
+ break;
33710
+ }
33711
+ return runs;
33712
+ }
33713
+ /**
33714
+ * Monospace families to try, in order — NOT one family and a generic.
33715
+ *
33716
+ * A terminal screen is mostly box-drawing and block characters, and a font
33717
+ * without them renders the frame as noise rather than as missing detail.
33718
+ * `DejaVu Sans Mono` covers them and ships in the hub image; macOS has neither
33719
+ * DejaVu nor fontconfig, so the Mac agent fell through to a generic whose
33720
+ * coverage is not, and its Glances camera came out unreadable while the hub's
33721
+ * was pixel-perfect (measured 2026-08-11, same Glances, two nodes).
33722
+ *
33723
+ * `Menlo` is the fix rather than a guess: it is macOS's default terminal face,
33724
+ * present on every install, and derived from DejaVu Sans Mono — the same glyph
33725
+ * coverage by ancestry. Monaco and Courier New follow as older fallbacks; the
33726
+ * generic stays last so a host with none of them still draws something.
33727
+ */
33728
+ var TERMINAL_FONT_STACK = "DejaVu Sans Mono,Menlo,Monaco,Courier New,monospace";
33729
+ var TERMINAL_FONT_SIZE = 13;
33730
+ var TERMINAL_TEXT_MARGIN_X = 8;
33731
+ var TERMINAL_ROW_HEIGHT = 15;
33732
+ var TERMINAL_BASELINE_Y = 18;
33733
+ /**
33734
+ * Distance from a row's baseline up to the top of its cell box. Chosen so
33735
+ * consecutive rows tile exactly: row N's box runs from `baseline - this` for
33736
+ * `TERMINAL_ROW_HEIGHT`, and row N+1's box starts where it ends. A background
33737
+ * bar that stopped short would draw as stripes across a `CSI 42m` panel.
33738
+ */
33739
+ var TERMINAL_CELL_ASCENT = 11.5;
33740
+ var TERMINAL_CELL_WIDTH = TERMINAL_FONT_SIZE * (1233 / 2048);
33741
+ /** Two decimals is under a tenth of a pixel and keeps the SVG small. */
33742
+ function coordinate(value) {
33743
+ return String(Number(value.toFixed(2)));
33744
+ }
33478
33745
  function escapeXml(value) {
33479
33746
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33480
33747
  }
33481
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33482
- async function renderTerminalJpeg(lines) {
33483
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33484
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
33485
- return (0, sharp.default)(Buffer.from(svg)).jpeg({
33748
+ /**
33749
+ * Render already-interpreted terminal rows into a compact MJPEG frame.
33750
+ *
33751
+ * `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
33752
+ * runs of whitespace by default, and a terminal's entire column alignment IS
33753
+ * runs of whitespace — Glances pads every field with spaces. Without it the
33754
+ * frame drew each line at roughly half its true width, crammed into the
33755
+ * top-left of a mostly-black image, while the SAME session over `attach`
33756
+ * looked perfect — which is exactly how the operator reported it. Measured in
33757
+ * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33758
+ * collapsed against 178 px preserved.
33759
+ *
33760
+ * Every run is anchored at its OWN column (`x = margin + startCol * cellW`),
33761
+ * never appended to the one before it, so the background rects and the glyphs
33762
+ * are placed off the same grid and cannot drift apart. `textLength` is emitted
33763
+ * with it because it is the correct declaration and renderers that honour it
33764
+ * get an exact grid — but it is not what makes this work: librsvg, which sharp
33765
+ * uses, ignores it outright (measured 2026-08-12, a 120-glyph run pinned to
33766
+ * 600 px still drew its natural 937 px). The anchoring is the guarantee.
33767
+ */
33768
+ function renderTerminalSvg(rows) {
33769
+ const backgrounds = [];
33770
+ const texts = [];
33771
+ rows.slice(0, 40).forEach((row, index) => {
33772
+ const baseline = TERMINAL_BASELINE_Y + index * TERMINAL_ROW_HEIGHT;
33773
+ const top = baseline - TERMINAL_CELL_ASCENT;
33774
+ let column = 0;
33775
+ for (const run of row) {
33776
+ if (column >= 120) break;
33777
+ const clipped = clipRun(run, 120 - column);
33778
+ const columns = [...clipped].length;
33779
+ if (columns === 0) continue;
33780
+ const x = TERMINAL_TEXT_MARGIN_X + column * TERMINAL_CELL_WIDTH;
33781
+ const width = columns * TERMINAL_CELL_WIDTH;
33782
+ if (run.bg !== null) backgrounds.push(`<rect x="${coordinate(x)}" y="${coordinate(top)}" width="${coordinate(width)}" height="${String(TERMINAL_ROW_HEIGHT)}" fill="${run.bg}"/>`);
33783
+ if (clipped.trim() !== "") {
33784
+ const fill = run.fg === null ? "" : ` fill="${run.fg}"`;
33785
+ const weight = run.bold ? " font-weight=\"bold\"" : "";
33786
+ texts.push(`<text xml:space="preserve" x="${coordinate(x)}" y="${coordinate(baseline)}" textLength="${coordinate(width)}" lengthAdjust="spacingAndGlyphs"${fill}${weight}>${escapeXml(clipped)}</text>`);
33787
+ }
33788
+ column += columns;
33789
+ }
33790
+ });
33791
+ 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>`;
33792
+ }
33793
+ /** Cut a run to the columns still left in the row, by code point not unit. */
33794
+ function clipRun(run, remaining) {
33795
+ const points = [...run.text];
33796
+ return points.length <= remaining ? run.text : points.slice(0, remaining).join("");
33797
+ }
33798
+ async function renderTerminalJpeg(rows) {
33799
+ return (0, sharp.default)(Buffer.from(renderTerminalSvg(rows))).jpeg({
33486
33800
  quality: 82,
33487
33801
  chromaSubsampling: "4:2:0"
33488
33802
  }).toBuffer();
@@ -38388,6 +38702,40 @@ function createXtermScreen(cols, rows) {
38388
38702
  for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
38389
38703
  return lines;
38390
38704
  },
38705
+ /**
38706
+ * The colour-bearing read. `translateToString` — the only cell read this
38707
+ * screen used to offer — keeps characters and discards every attribute,
38708
+ * which is why the terminal camera rendered a fully-coloured Glances in
38709
+ * grey. Walking cells is the only way to get the attributes back.
38710
+ *
38711
+ * A zero-width cell is the tail half of a wide glyph (CJK, some emoji);
38712
+ * its characters already came back with the leading cell, so skipping it
38713
+ * keeps the text right. `getNullCell()` is reused across the whole screen
38714
+ * because `getCell` allocates otherwise — 4800 objects per frame.
38715
+ */
38716
+ styledLines: () => {
38717
+ const buffer = term.buffer.active;
38718
+ const scratch = buffer.getNullCell();
38719
+ const styled = [];
38720
+ for (let row = 0; row < term.rows; row += 1) {
38721
+ const line = buffer.getLine(buffer.viewportY + row);
38722
+ if (!line) {
38723
+ styled.push([]);
38724
+ continue;
38725
+ }
38726
+ const cells = [];
38727
+ for (let column = 0; column < line.length; column += 1) {
38728
+ const cell = line.getCell(column, scratch);
38729
+ if (!cell || cell.getWidth() === 0) continue;
38730
+ cells.push({
38731
+ text: cell.getChars() || " ",
38732
+ style: resolveCellStyle(cell)
38733
+ });
38734
+ }
38735
+ styled.push(buildCellRuns(cells));
38736
+ }
38737
+ return styled;
38738
+ },
38391
38739
  dispose: () => {
38392
38740
  term.dispose();
38393
38741
  }
@@ -38398,6 +38746,9 @@ function createXtermScreen(cols, rows) {
38398
38746
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
38399
38747
  var SESSION_IDLE_MS = 3e4;
38400
38748
  var SNAPSHOT_STARTUP_WAIT_MS = 1500;
38749
+ var SNAPSHOT_SETTLE_QUIET_MS = 300;
38750
+ var SNAPSHOT_SETTLE_BUDGET_MS = 3e3;
38751
+ var SNAPSHOT_SETTLE_MAX_PULLS = 20;
38401
38752
  var CLOSE_RETRY_BASE_MS = 50;
38402
38753
  var CLOSE_RETRY_MAX_MS = 1e3;
38403
38754
  var CLOSE_ATTEMPTS_PER_PASS = 3;
@@ -38522,20 +38873,36 @@ var TerminalCameraRelay = class {
38522
38873
  if (state.openPromise === opening) state.openPromise = null;
38523
38874
  }
38524
38875
  }
38876
+ /** Replay one output batch into the relay screen; true = the session exited. */
38877
+ applyBatch(state, batch) {
38878
+ if (batch.reset) {
38879
+ state.screen.dispose();
38880
+ state.screen = createXtermScreen(120, 40);
38881
+ if (batch.snapshot) state.screen.write(batch.snapshot);
38882
+ }
38883
+ let exited = false;
38884
+ for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
38885
+ else {
38886
+ state.sessionId = null;
38887
+ exited = true;
38888
+ }
38889
+ state.cursor = exited ? 0 : batch.cursor;
38890
+ return exited;
38891
+ }
38525
38892
  async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38526
38893
  if (state.framePromise) {
38527
38894
  await state.framePromise;
38528
38895
  return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38529
38896
  }
38530
38897
  const render = async () => {
38531
- const openingSession = state.sessionId === null;
38898
+ const initialPull = state.sessionId === null && waitForInitialOutput;
38532
38899
  const sessionId = await this.ensureSession(state);
38533
38900
  let batch;
38534
38901
  try {
38535
38902
  batch = await this.api.pullOutput(state.nodeId, {
38536
38903
  sessionId,
38537
38904
  afterSeq: state.cursor,
38538
- ...openingSession && waitForInitialOutput ? {
38905
+ ...initialPull ? {
38539
38906
  waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38540
38907
  waitForOutput: true
38541
38908
  } : {}
@@ -38557,21 +38924,29 @@ var TerminalCameraRelay = class {
38557
38924
  state.cursor = 0;
38558
38925
  throw error;
38559
38926
  }
38560
- if (batch.reset) {
38561
- state.screen.dispose();
38562
- state.screen = createXtermScreen(120, 40);
38563
- if (batch.snapshot) state.screen.write(batch.snapshot);
38564
- }
38565
- let exited = false;
38566
- for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
38567
- else {
38568
- state.sessionId = null;
38569
- exited = true;
38927
+ let exited = this.applyBatch(state, batch);
38928
+ if (initialPull && !exited) {
38929
+ const deadline = Date.now() + SNAPSHOT_SETTLE_BUDGET_MS;
38930
+ for (let pulls = 0; pulls < SNAPSHOT_SETTLE_MAX_PULLS && Date.now() < deadline; pulls += 1) {
38931
+ let settle;
38932
+ try {
38933
+ settle = await this.api.pullOutput(state.nodeId, {
38934
+ sessionId,
38935
+ afterSeq: state.cursor,
38936
+ waitMs: SNAPSHOT_SETTLE_QUIET_MS,
38937
+ waitForOutput: true
38938
+ });
38939
+ } catch {
38940
+ break;
38941
+ }
38942
+ const active = settle.reset || settle.events.some((event) => event.kind === "data");
38943
+ exited = this.applyBatch(state, settle);
38944
+ if (exited || !active) break;
38945
+ }
38570
38946
  }
38571
- state.cursor = exited ? 0 : batch.cursor;
38572
38947
  await state.screen.flush();
38573
38948
  if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38574
- state.jpeg = await renderTerminalJpeg(state.screen.lines());
38949
+ state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
38575
38950
  state.renderedCursor = state.cursor;
38576
38951
  }
38577
38952
  return state.jpeg;
@@ -39020,7 +39395,8 @@ function buildProfiles(options) {
39020
39395
  "-m",
39021
39396
  "glances",
39022
39397
  ...options.glancesArgs ?? []
39023
- ]
39398
+ ],
39399
+ ...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
39024
39400
  });
39025
39401
  }
39026
39402
  if (options.allowShell) profiles.push({
@@ -39105,6 +39481,7 @@ var TerminalSessionManager = class {
39105
39481
  glancesPath: opts.glancesPath,
39106
39482
  glancesArgs: opts.glancesArgs,
39107
39483
  glancesPythonPath: opts.glancesPythonPath,
39484
+ glancesEnv: opts.glancesEnv,
39108
39485
  allowShell: opts.allowShell,
39109
39486
  shellPath: opts.shellPath,
39110
39487
  customProfiles: opts.customProfiles,
@@ -39402,6 +39779,18 @@ var TerminalSessionManager = class {
39402
39779
  };
39403
39780
  //#endregion
39404
39781
  //#region src/addon.ts
39782
+ /**
39783
+ * Terminal addon — hosts interactive pty sessions running an allowlisted
39784
+ * profile (`monitor` → `btm`), streamed to the Admin UI over the data plane.
39785
+ *
39786
+ * Phase 1 of docs/design/2026-07-26-terminal-session-design.md: a real TTY good
39787
+ * enough to run a full-screen program. Phase 2 (streaming a session as a camera)
39788
+ * is a separate device-provider concern and does not change this addon's cap.
39789
+ *
39790
+ * Its own package (not a builtin) because it carries a native dependency
39791
+ * (`node-pty`) and should update via `camstack deploy` independently of the
39792
+ * framework, exactly like the node-av decoder addon.
39793
+ */
39405
39794
  var DEFAULTS = {
39406
39795
  btmPath: "",
39407
39796
  btmEnabled: true,
@@ -39434,12 +39823,21 @@ var TerminalAddon = class extends BaseAddon {
39434
39823
  instanceMutationQueue = new TerminalInstanceMutationQueue();
39435
39824
  terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
39436
39825
  glancesPythonPath = "";
39826
+ glancesEnv;
39437
39827
  constructor() {
39438
39828
  super({ ...DEFAULTS });
39439
39829
  }
39440
39830
  async onInitialize() {
39441
39831
  await warmNodePty();
39442
39832
  this.glancesPythonPath = await this.ctx.deps.ensurePython() ?? "";
39833
+ const shimDir = cursesShimDir();
39834
+ this.glancesEnv = resolveGlancesCursesShimEnv({
39835
+ platform: process.platform,
39836
+ shimDir,
39837
+ shimDirExists: cursesShimDirExists(shimDir),
39838
+ existingPythonPath: process.env["PYTHONPATH"]
39839
+ });
39840
+ this.probeGlancesCursesColours();
39443
39841
  const manager = new TerminalSessionManager({
39444
39842
  spawn: createNodePtySpawner(),
39445
39843
  screenFactory: createXtermScreen,
@@ -39455,6 +39853,7 @@ var TerminalAddon = class extends BaseAddon {
39455
39853
  glancesPath: this.config.glancesPath,
39456
39854
  glancesArgs: this.config.glancesArgs,
39457
39855
  glancesPythonPath: this.glancesPythonPath,
39856
+ glancesEnv: this.glancesEnv,
39458
39857
  allowShell: this.config.allowShell,
39459
39858
  shellPath: this.config.shellPath,
39460
39859
  maxSessions: this.config.maxSessions,
@@ -39500,6 +39899,39 @@ var TerminalAddon = class extends BaseAddon {
39500
39899
  provider: manager
39501
39900
  }];
39502
39901
  }
39902
+ /**
39903
+ * Fire-and-forget canary: the managed python must see a colour-capable
39904
+ * curses, or glances paints its B&W theme (SGR conceal/reverse) and the
39905
+ * terminal camera frames lose their text. The shim filename is ABI-locked
39906
+ * (cpython-312), so a managed-python bump silently disables it — this probe
39907
+ * is what makes that failure loud instead of a garbled camera.
39908
+ */
39909
+ probeGlancesCursesColours() {
39910
+ if (!this.glancesPythonPath) return;
39911
+ const probeEnv = {
39912
+ ...process.env,
39913
+ ...this.glancesEnv,
39914
+ TERM: "xterm-256color"
39915
+ };
39916
+ (0, node_child_process.execFile)(this.glancesPythonPath, ["-c", "import curses\ncurses.setupterm('xterm-256color')\nprint(curses.tigetnum('colors'))"], {
39917
+ env: probeEnv,
39918
+ timeout: 1e4
39919
+ }, (error, stdout) => {
39920
+ const colours = error ? NaN : Number.parseInt(stdout.trim(), 10);
39921
+ if (!Number.isFinite(colours) || colours <= 0) {
39922
+ this.ctx.logger.warn("glances curses reports no colours — terminal camera frames will render the B&W theme", { meta: {
39923
+ colours: Number.isFinite(colours) ? colours : null,
39924
+ shimApplied: this.glancesEnv !== void 0,
39925
+ error: error ? error instanceof Error ? error.message : String(error) : void 0
39926
+ } });
39927
+ return;
39928
+ }
39929
+ this.ctx.logger.debug("glances curses colours available", { meta: {
39930
+ colours,
39931
+ shimApplied: this.glancesEnv !== void 0
39932
+ } });
39933
+ });
39934
+ }
39503
39935
  async onConfigChanged() {
39504
39936
  this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
39505
39937
  this.manager?.reconfigureProfiles({
@@ -39513,6 +39945,7 @@ var TerminalAddon = class extends BaseAddon {
39513
39945
  glancesPath: this.config.glancesPath,
39514
39946
  glancesArgs: this.config.glancesArgs,
39515
39947
  glancesPythonPath: this.glancesPythonPath,
39948
+ glancesEnv: this.glancesEnv,
39516
39949
  allowShell: this.config.allowShell,
39517
39950
  shellPath: this.config.shellPath,
39518
39951
  maxSessions: this.config.maxSessions,
@@ -39608,12 +40041,46 @@ var TerminalAddon = class extends BaseAddon {
39608
40041
  this.migratedTerminalCameraConfigIds.add(outcome.device.id);
39609
40042
  }
39610
40043
  }
40044
+ if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
39611
40045
  const nodeId = outcome.device.config.get("nodeId");
39612
40046
  const profileId = outcome.device.config.get("profileId");
39613
40047
  const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
39614
40048
  outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
39615
40049
  }
39616
40050
  }
40051
+ /**
40052
+ * A Terminal camera is a rendered screen. Object detection on it finds
40053
+ * nothing, forever, at full cost.
40054
+ *
40055
+ * Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
40056
+ * through the detection pipeline at ~61 ms of inference each, plus 115
40057
+ * capture-scheduler requests a minute — against `detections=0`. Multiply by
40058
+ * one terminal per node and it is a standing tax on a hub that was already
40059
+ * shedding 86 % of its capture queue.
40060
+ *
40061
+ * Written through `setCameraSwitch`, which is the authority that already owns
40062
+ * this function — [D62] forbids a second store that disagrees with it. And
40063
+ * written ONLY on creation: an operator who deliberately turns detection back
40064
+ * on for a terminal must win, and a reconcile that re-asserted every pass
40065
+ * would silently overrule them once a minute.
40066
+ */
40067
+ async silenceAnalysisFor(deviceId) {
40068
+ for (const switchId of ["object-detection", "audio-analysis"]) try {
40069
+ await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
40070
+ deviceId,
40071
+ switchId,
40072
+ enabled: false
40073
+ });
40074
+ } catch (err) {
40075
+ this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
40076
+ tags: { deviceId },
40077
+ meta: {
40078
+ switchId,
40079
+ error: err instanceof Error ? err.message : String(err)
40080
+ }
40081
+ });
40082
+ }
40083
+ }
39617
40084
  terminalInstanceControl() {
39618
40085
  return {
39619
40086
  listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
@@ -39925,11 +40392,17 @@ var TerminalAddon = class extends BaseAddon {
39925
40392
  }
39926
40393
  };
39927
40394
  //#endregion
40395
+ exports.TERMINAL_DEFAULT_BG = TERMINAL_DEFAULT_BG;
40396
+ exports.TERMINAL_DEFAULT_FG = TERMINAL_DEFAULT_FG;
39928
40397
  exports.TerminalAddon = TerminalAddon;
39929
40398
  exports.TerminalSessionManager = TerminalSessionManager;
40399
+ exports.buildCellRuns = buildCellRuns;
39930
40400
  exports.buildProfiles = buildProfiles;
39931
40401
  exports.createNodePtySpawner = createNodePtySpawner;
39932
40402
  exports.createTerminalDataPlaneHandler = createTerminalDataPlaneHandler;
39933
40403
  exports.createXtermScreen = createXtermScreen;
39934
40404
  exports.findProfile = findProfile;
40405
+ exports.resolveCellStyle = resolveCellStyle;
40406
+ exports.terminalPaletteColor = terminalPaletteColor;
40407
+ exports.terminalRgbColor = terminalRgbColor;
39935
40408
  exports.warmNodePty = warmNodePty;