@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 is what enrols
18410
- * a camera in the keep-warm loop. Both of those are satisfiable by the
18411
- * client's own image cache — `expo-image` is URL-keyed and never revalidates
18412
- * — so a URL painted in a previous session comes off disk with no network,
18413
- * no enrolment, and nothing warming. Measured on the live hub: reopening
18414
- * after two minutes idle painted 15 of 16 tiles at **168 s old** with zero
18415
- * HTTP requests, and the fleet only recovered because a later poll happened
18416
- * to observe a different identity.
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 MAY create keep-warm
18422
- * subscriptions, where `getSnapshotOverview` must never (D93) — the
18423
- * distinction is not "one is newer" but that the overview poll is app-wide
18424
- * (a creating overview would warm every camera on the install) while this is
18425
- * called by a rendered surface naming the tiles it is actually painting, at
18426
- * the width it is painting them.
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33458
33724
  }
33459
- /** Render already-interpreted terminal rows into a compact MJPEG frame. */
33460
- async function renderTerminalJpeg(lines) {
33461
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33462
- 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>`;
33463
- return sharp(Buffer.from(svg)).jpeg({
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 openingSession = state.sessionId === null;
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
- ...openingSession && waitForInitialOutput ? {
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
- if (batch.reset) {
38539
- state.screen.dispose();
38540
- state.screen = createXtermScreen(120, 40);
38541
- if (batch.snapshot) state.screen.write(batch.snapshot);
38542
- }
38543
- let exited = false;
38544
- for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
38545
- else {
38546
- state.sessionId = null;
38547
- exited = true;
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.lines());
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, createNodePtySpawner as o, findProfile as r, warmNodePty as s, TerminalSessionManager as t };
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 };