@camstack/addon-terminal 0.1.13 → 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");
@@ -33377,6 +33380,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33377
33380
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33378
33381
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33379
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
33380
33423
  //#region src/pty.ts
33381
33424
  /**
33382
33425
  * Minimal pty abstraction. The manager depends on this interface, never on
@@ -33507,6 +33550,198 @@ function buildTerminalInstanceCameraDeclarations(instances) {
33507
33550
  function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33508
33551
  return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33509
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
+ }
33510
33745
  function escapeXml(value) {
33511
33746
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33512
33747
  }
@@ -33521,11 +33756,47 @@ function escapeXml(value) {
33521
33756
  * looked perfect — which is exactly how the operator reported it. Measured in
33522
33757
  * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33523
33758
  * collapsed against 178 px preserved.
33524
- */
33525
- async function renderTerminalJpeg(lines) {
33526
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33527
- 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>`;
33528
- return (0, sharp.default)(Buffer.from(svg)).jpeg({
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({
33529
33800
  quality: 82,
33530
33801
  chromaSubsampling: "4:2:0"
33531
33802
  }).toBuffer();
@@ -38431,6 +38702,40 @@ function createXtermScreen(cols, rows) {
38431
38702
  for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
38432
38703
  return lines;
38433
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
+ },
38434
38739
  dispose: () => {
38435
38740
  term.dispose();
38436
38741
  }
@@ -38441,6 +38746,9 @@ function createXtermScreen(cols, rows) {
38441
38746
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
38442
38747
  var SESSION_IDLE_MS = 3e4;
38443
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;
38444
38752
  var CLOSE_RETRY_BASE_MS = 50;
38445
38753
  var CLOSE_RETRY_MAX_MS = 1e3;
38446
38754
  var CLOSE_ATTEMPTS_PER_PASS = 3;
@@ -38565,20 +38873,36 @@ var TerminalCameraRelay = class {
38565
38873
  if (state.openPromise === opening) state.openPromise = null;
38566
38874
  }
38567
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
+ }
38568
38892
  async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38569
38893
  if (state.framePromise) {
38570
38894
  await state.framePromise;
38571
38895
  return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38572
38896
  }
38573
38897
  const render = async () => {
38574
- const openingSession = state.sessionId === null;
38898
+ const initialPull = state.sessionId === null && waitForInitialOutput;
38575
38899
  const sessionId = await this.ensureSession(state);
38576
38900
  let batch;
38577
38901
  try {
38578
38902
  batch = await this.api.pullOutput(state.nodeId, {
38579
38903
  sessionId,
38580
38904
  afterSeq: state.cursor,
38581
- ...openingSession && waitForInitialOutput ? {
38905
+ ...initialPull ? {
38582
38906
  waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38583
38907
  waitForOutput: true
38584
38908
  } : {}
@@ -38600,21 +38924,29 @@ var TerminalCameraRelay = class {
38600
38924
  state.cursor = 0;
38601
38925
  throw error;
38602
38926
  }
38603
- if (batch.reset) {
38604
- state.screen.dispose();
38605
- state.screen = createXtermScreen(120, 40);
38606
- if (batch.snapshot) state.screen.write(batch.snapshot);
38607
- }
38608
- let exited = false;
38609
- for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
38610
- else {
38611
- state.sessionId = null;
38612
- 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
+ }
38613
38946
  }
38614
- state.cursor = exited ? 0 : batch.cursor;
38615
38947
  await state.screen.flush();
38616
38948
  if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38617
- state.jpeg = await renderTerminalJpeg(state.screen.lines());
38949
+ state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
38618
38950
  state.renderedCursor = state.cursor;
38619
38951
  }
38620
38952
  return state.jpeg;
@@ -39063,7 +39395,8 @@ function buildProfiles(options) {
39063
39395
  "-m",
39064
39396
  "glances",
39065
39397
  ...options.glancesArgs ?? []
39066
- ]
39398
+ ],
39399
+ ...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
39067
39400
  });
39068
39401
  }
39069
39402
  if (options.allowShell) profiles.push({
@@ -39148,6 +39481,7 @@ var TerminalSessionManager = class {
39148
39481
  glancesPath: opts.glancesPath,
39149
39482
  glancesArgs: opts.glancesArgs,
39150
39483
  glancesPythonPath: opts.glancesPythonPath,
39484
+ glancesEnv: opts.glancesEnv,
39151
39485
  allowShell: opts.allowShell,
39152
39486
  shellPath: opts.shellPath,
39153
39487
  customProfiles: opts.customProfiles,
@@ -39445,6 +39779,18 @@ var TerminalSessionManager = class {
39445
39779
  };
39446
39780
  //#endregion
39447
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
+ */
39448
39794
  var DEFAULTS = {
39449
39795
  btmPath: "",
39450
39796
  btmEnabled: true,
@@ -39477,12 +39823,21 @@ var TerminalAddon = class extends BaseAddon {
39477
39823
  instanceMutationQueue = new TerminalInstanceMutationQueue();
39478
39824
  terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
39479
39825
  glancesPythonPath = "";
39826
+ glancesEnv;
39480
39827
  constructor() {
39481
39828
  super({ ...DEFAULTS });
39482
39829
  }
39483
39830
  async onInitialize() {
39484
39831
  await warmNodePty();
39485
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();
39486
39841
  const manager = new TerminalSessionManager({
39487
39842
  spawn: createNodePtySpawner(),
39488
39843
  screenFactory: createXtermScreen,
@@ -39498,6 +39853,7 @@ var TerminalAddon = class extends BaseAddon {
39498
39853
  glancesPath: this.config.glancesPath,
39499
39854
  glancesArgs: this.config.glancesArgs,
39500
39855
  glancesPythonPath: this.glancesPythonPath,
39856
+ glancesEnv: this.glancesEnv,
39501
39857
  allowShell: this.config.allowShell,
39502
39858
  shellPath: this.config.shellPath,
39503
39859
  maxSessions: this.config.maxSessions,
@@ -39543,6 +39899,39 @@ var TerminalAddon = class extends BaseAddon {
39543
39899
  provider: manager
39544
39900
  }];
39545
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
+ }
39546
39935
  async onConfigChanged() {
39547
39936
  this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
39548
39937
  this.manager?.reconfigureProfiles({
@@ -39556,6 +39945,7 @@ var TerminalAddon = class extends BaseAddon {
39556
39945
  glancesPath: this.config.glancesPath,
39557
39946
  glancesArgs: this.config.glancesArgs,
39558
39947
  glancesPythonPath: this.glancesPythonPath,
39948
+ glancesEnv: this.glancesEnv,
39559
39949
  allowShell: this.config.allowShell,
39560
39950
  shellPath: this.config.shellPath,
39561
39951
  maxSessions: this.config.maxSessions,
@@ -40002,11 +40392,17 @@ var TerminalAddon = class extends BaseAddon {
40002
40392
  }
40003
40393
  };
40004
40394
  //#endregion
40395
+ exports.TERMINAL_DEFAULT_BG = TERMINAL_DEFAULT_BG;
40396
+ exports.TERMINAL_DEFAULT_FG = TERMINAL_DEFAULT_FG;
40005
40397
  exports.TerminalAddon = TerminalAddon;
40006
40398
  exports.TerminalSessionManager = TerminalSessionManager;
40399
+ exports.buildCellRuns = buildCellRuns;
40007
40400
  exports.buildProfiles = buildProfiles;
40008
40401
  exports.createNodePtySpawner = createNodePtySpawner;
40009
40402
  exports.createTerminalDataPlaneHandler = createTerminalDataPlaneHandler;
40010
40403
  exports.createXtermScreen = createXtermScreen;
40011
40404
  exports.findProfile = findProfile;
40405
+ exports.resolveCellStyle = resolveCellStyle;
40406
+ exports.terminalPaletteColor = terminalPaletteColor;
40407
+ exports.terminalRgbColor = terminalRgbColor;
40012
40408
  exports.warmNodePty = warmNodePty;
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
@@ -33355,6 +33357,46 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
33355
33357
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
33356
33358
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
33357
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
33358
33400
  //#region src/pty.ts
33359
33401
  /**
33360
33402
  * Minimal pty abstraction. The manager depends on this interface, never on
@@ -33485,6 +33527,198 @@ function buildTerminalInstanceCameraDeclarations(instances) {
33485
33527
  function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
33486
33528
  return persistedConfig.instanceId !== declaration.config.instanceId || persistedConfig.nodeId !== declaration.config.nodeId || persistedConfig.profileId !== declaration.config.profileId || persistedConfig.profileLabel !== declaration.config.profileLabel;
33487
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
+ }
33488
33722
  function escapeXml(value) {
33489
33723
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
33490
33724
  }
@@ -33499,11 +33733,47 @@ function escapeXml(value) {
33499
33733
  * looked perfect — which is exactly how the operator reported it. Measured in
33500
33734
  * the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
33501
33735
  * collapsed against 178 px preserved.
33502
- */
33503
- async function renderTerminalJpeg(lines) {
33504
- const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
33505
- 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>`;
33506
- return sharp(Buffer.from(svg)).jpeg({
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({
33507
33777
  quality: 82,
33508
33778
  chromaSubsampling: "4:2:0"
33509
33779
  }).toBuffer();
@@ -38409,6 +38679,40 @@ function createXtermScreen(cols, rows) {
38409
38679
  for (let row = 0; row < term.rows; row += 1) lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
38410
38680
  return lines;
38411
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
+ },
38412
38716
  dispose: () => {
38413
38717
  term.dispose();
38414
38718
  }
@@ -38419,6 +38723,9 @@ function createXtermScreen(cols, rows) {
38419
38723
  var MJPEG_BOUNDARY = "camstack-terminal-frame";
38420
38724
  var SESSION_IDLE_MS = 3e4;
38421
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;
38422
38729
  var CLOSE_RETRY_BASE_MS = 50;
38423
38730
  var CLOSE_RETRY_MAX_MS = 1e3;
38424
38731
  var CLOSE_ATTEMPTS_PER_PASS = 3;
@@ -38543,20 +38850,36 @@ var TerminalCameraRelay = class {
38543
38850
  if (state.openPromise === opening) state.openPromise = null;
38544
38851
  }
38545
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
+ }
38546
38869
  async nextFrame(state, forceRender = false, waitForInitialOutput = false) {
38547
38870
  if (state.framePromise) {
38548
38871
  await state.framePromise;
38549
38872
  return forceRender ? this.nextFrame(state, true, waitForInitialOutput) : state.jpeg ?? this.nextFrame(state, false, waitForInitialOutput);
38550
38873
  }
38551
38874
  const render = async () => {
38552
- const openingSession = state.sessionId === null;
38875
+ const initialPull = state.sessionId === null && waitForInitialOutput;
38553
38876
  const sessionId = await this.ensureSession(state);
38554
38877
  let batch;
38555
38878
  try {
38556
38879
  batch = await this.api.pullOutput(state.nodeId, {
38557
38880
  sessionId,
38558
38881
  afterSeq: state.cursor,
38559
- ...openingSession && waitForInitialOutput ? {
38882
+ ...initialPull ? {
38560
38883
  waitMs: SNAPSHOT_STARTUP_WAIT_MS,
38561
38884
  waitForOutput: true
38562
38885
  } : {}
@@ -38578,21 +38901,29 @@ var TerminalCameraRelay = class {
38578
38901
  state.cursor = 0;
38579
38902
  throw error;
38580
38903
  }
38581
- if (batch.reset) {
38582
- state.screen.dispose();
38583
- state.screen = createXtermScreen(120, 40);
38584
- if (batch.snapshot) state.screen.write(batch.snapshot);
38585
- }
38586
- let exited = false;
38587
- for (const event of batch.events) if (event.kind === "data") state.screen.write(event.data);
38588
- else {
38589
- state.sessionId = null;
38590
- 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
+ }
38591
38923
  }
38592
- state.cursor = exited ? 0 : batch.cursor;
38593
38924
  await state.screen.flush();
38594
38925
  if (forceRender || state.jpeg === null || state.renderedCursor !== state.cursor) {
38595
- state.jpeg = await renderTerminalJpeg(state.screen.lines());
38926
+ state.jpeg = await renderTerminalJpeg(state.screen.styledLines());
38596
38927
  state.renderedCursor = state.cursor;
38597
38928
  }
38598
38929
  return state.jpeg;
@@ -39041,7 +39372,8 @@ function buildProfiles(options) {
39041
39372
  "-m",
39042
39373
  "glances",
39043
39374
  ...options.glancesArgs ?? []
39044
- ]
39375
+ ],
39376
+ ...options.glancesEnv && Object.keys(options.glancesEnv).length > 0 ? { env: options.glancesEnv } : {}
39045
39377
  });
39046
39378
  }
39047
39379
  if (options.allowShell) profiles.push({
@@ -39126,6 +39458,7 @@ var TerminalSessionManager = class {
39126
39458
  glancesPath: opts.glancesPath,
39127
39459
  glancesArgs: opts.glancesArgs,
39128
39460
  glancesPythonPath: opts.glancesPythonPath,
39461
+ glancesEnv: opts.glancesEnv,
39129
39462
  allowShell: opts.allowShell,
39130
39463
  shellPath: opts.shellPath,
39131
39464
  customProfiles: opts.customProfiles,
@@ -39423,6 +39756,18 @@ var TerminalSessionManager = class {
39423
39756
  };
39424
39757
  //#endregion
39425
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
+ */
39426
39771
  var DEFAULTS = {
39427
39772
  btmPath: "",
39428
39773
  btmEnabled: true,
@@ -39455,12 +39800,21 @@ var TerminalAddon = class extends BaseAddon {
39455
39800
  instanceMutationQueue = new TerminalInstanceMutationQueue();
39456
39801
  terminalReconcileCoordinator = new TerminalInstanceReconcileCoordinator(this.instanceMutationQueue);
39457
39802
  glancesPythonPath = "";
39803
+ glancesEnv;
39458
39804
  constructor() {
39459
39805
  super({ ...DEFAULTS });
39460
39806
  }
39461
39807
  async onInitialize() {
39462
39808
  await warmNodePty();
39463
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();
39464
39818
  const manager = new TerminalSessionManager({
39465
39819
  spawn: createNodePtySpawner(),
39466
39820
  screenFactory: createXtermScreen,
@@ -39476,6 +39830,7 @@ var TerminalAddon = class extends BaseAddon {
39476
39830
  glancesPath: this.config.glancesPath,
39477
39831
  glancesArgs: this.config.glancesArgs,
39478
39832
  glancesPythonPath: this.glancesPythonPath,
39833
+ glancesEnv: this.glancesEnv,
39479
39834
  allowShell: this.config.allowShell,
39480
39835
  shellPath: this.config.shellPath,
39481
39836
  maxSessions: this.config.maxSessions,
@@ -39521,6 +39876,39 @@ var TerminalAddon = class extends BaseAddon {
39521
39876
  provider: manager
39522
39877
  }];
39523
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
+ }
39524
39912
  async onConfigChanged() {
39525
39913
  this.replaceTerminalCameraTombstones(this.config.terminalCameraTombstones);
39526
39914
  this.manager?.reconfigureProfiles({
@@ -39534,6 +39922,7 @@ var TerminalAddon = class extends BaseAddon {
39534
39922
  glancesPath: this.config.glancesPath,
39535
39923
  glancesArgs: this.config.glancesArgs,
39536
39924
  glancesPythonPath: this.glancesPythonPath,
39925
+ glancesEnv: this.glancesEnv,
39537
39926
  allowShell: this.config.allowShell,
39538
39927
  shellPath: this.config.shellPath,
39539
39928
  maxSessions: this.config.maxSessions,
@@ -39980,4 +40369,4 @@ var TerminalAddon = class extends BaseAddon {
39980
40369
  }
39981
40370
  };
39982
40371
  //#endregion
39983
- 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 };
package/dist/index.js CHANGED
@@ -3,12 +3,18 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_addon = require("./addon.js");
6
+ exports.TERMINAL_DEFAULT_BG = require_addon.TERMINAL_DEFAULT_BG;
7
+ exports.TERMINAL_DEFAULT_FG = require_addon.TERMINAL_DEFAULT_FG;
6
8
  exports.TerminalAddon = require_addon.TerminalAddon;
7
9
  exports.TerminalSessionManager = require_addon.TerminalSessionManager;
10
+ exports.buildCellRuns = require_addon.buildCellRuns;
8
11
  exports.buildProfiles = require_addon.buildProfiles;
9
12
  exports.createNodePtySpawner = require_addon.createNodePtySpawner;
10
13
  exports.createTerminalDataPlaneHandler = require_addon.createTerminalDataPlaneHandler;
11
14
  exports.createXtermScreen = require_addon.createXtermScreen;
12
15
  exports.default = require_addon.TerminalAddon;
13
16
  exports.findProfile = require_addon.findProfile;
17
+ exports.resolveCellStyle = require_addon.resolveCellStyle;
18
+ exports.terminalPaletteColor = require_addon.terminalPaletteColor;
19
+ exports.terminalRgbColor = require_addon.terminalRgbColor;
14
20
  exports.warmNodePty = require_addon.warmNodePty;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { TerminalAddon, a as createXtermScreen, i as createTerminalDataPlaneHandler, n as buildProfiles, o as createNodePtySpawner, r as findProfile, s as warmNodePty, t as TerminalSessionManager } from "./addon.mjs";
2
- export { TerminalAddon, TerminalAddon as default, TerminalSessionManager, buildProfiles, createNodePtySpawner, createTerminalDataPlaneHandler, createXtermScreen, findProfile, warmNodePty };
1
+ import { TerminalAddon, a as createXtermScreen, c as buildCellRuns, d as terminalRgbColor, f as createNodePtySpawner, i as createTerminalDataPlaneHandler, l as resolveCellStyle, n as buildProfiles, o as TERMINAL_DEFAULT_BG, p as warmNodePty, r as findProfile, s as TERMINAL_DEFAULT_FG, t as TerminalSessionManager, u as terminalPaletteColor } from "./addon.mjs";
2
+ export { TERMINAL_DEFAULT_BG, TERMINAL_DEFAULT_FG, TerminalAddon, TerminalAddon as default, TerminalSessionManager, buildCellRuns, buildProfiles, createNodePtySpawner, createTerminalDataPlaneHandler, createXtermScreen, findProfile, resolveCellStyle, terminalPaletteColor, terminalRgbColor, warmNodePty };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-terminal",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Interactive terminal sessions (pty + xterm) as a CamStack addon",
5
5
  "keywords": [
6
6
  "camstack",