` and colors red with `var(--vt-palette-1)`.
+
+```sh
+$S/drive.sh "$RUN_ROOT" text-formatters <<'JS'
+import assert from "node:assert/strict";
+import { createTerminal } from "@coder/libghostty-vt-node";
+const t = createTerminal({ cols: 20, rows: 3 });
+t.feed("hello\r\n\x1b[31mred\x1b[0m");
+const plain = t.formatPlain(), html = t.formatHtml();
+console.log(JSON.stringify(plain)); console.log(html);
+assert.equal(plain, "hello\nred");
+assert.ok(html.startsWith('
'));
+assert.match(html, /color: var\(--vt-palette-1\);[^>]*>red);
+t.dispose();
+JS
+```
+
+## Gotchas
+
+- `\n` alone moves down without a carriage return. Feed `\r\n` when the test expects each line to start at column 0.
+- `getVisibleText()` returns every row, with trailing spaces trimmed, joined by `\n`. Empty rows give trailing `\n` characters.
+- `formatPlain()` trims trailing empty rows. `getVisibleText()` does not. Do not compare the two strings directly.
+- The HTML uses CSS variables (`--vt-palette-N`) for palette colors, not hex values. A snapshot gives hex values.
diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/cleanup.sh b/.agents/skills/verify-libghostty-vt-node/scripts/cleanup.sh
new file mode 100755
index 0000000..fd4167f
--- /dev/null
+++ b/.agents/skills/verify-libghostty-vt-node/scripts/cleanup.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Remove the instance that launch.sh made in RUN_ROOT. Keep RUN_ROOT/evidence.
+# Usage: .agents/skills/verify-libghostty-vt-node/scripts/cleanup.sh RUN_ROOT
+set -euo pipefail
+
+RUN_ROOT="${1:?usage: cleanup.sh RUN_ROOT}"
+INSTANCE="$RUN_ROOT/instance"
+if [[ ! -e "$INSTANCE" ]]; then
+ echo "nothing to remove: no $INSTANCE"
+else
+ [[ -f "$INSTANCE/.verify-libghostty-vt-node" ]] || {
+ echo "error: $INSTANCE has no .verify-libghostty-vt-node marker from launch.sh; not removing it" >&2
+ exit 2
+ }
+ rm -rf "$INSTANCE"
+ echo "removed $INSTANCE"
+fi
+if [[ -d "$RUN_ROOT/evidence" ]]; then
+ echo "evidence kept in $RUN_ROOT/evidence:"
+ ls -1 "$RUN_ROOT/evidence"
+fi
diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh
new file mode 100755
index 0000000..f5fe563
--- /dev/null
+++ b/.agents/skills/verify-libghostty-vt-node/scripts/doctor.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# Read-only check: is the instance in RUN_ROOT worth driving?
+# Usage: .agents/skills/verify-libghostty-vt-node/scripts/doctor.sh RUN_ROOT
+# Exit: 0 = every check passed (WARN lines allowed), 1 = a check failed.
+set -uo pipefail
+
+SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)"
+RUN_ROOT="${1:?usage: doctor.sh RUN_ROOT}"
+INSTANCE="$RUN_ROOT/instance"
+fail=0
+ok() { echo "ok $*"; }
+bad() { echo "FAIL $*"; fail=1; }
+warn() { echo "WARN $*"; }
+
+if [[ ! -f "$INSTANCE/instance.json" ]]; then
+ bad "no $INSTANCE/instance.json: run launch.sh first"
+ exit 1
+fi
+ok "instance.json: $INSTANCE/instance.json"
+# Physical path: node reports real paths, and RUN_ROOT can sit behind a symlink.
+CONSUMER="$(cd "$INSTANCE/consumer" && pwd -P)"
+
+field() { node -e 'const i=require(process.argv[1]); process.stdout.write(String(i[process.argv[2]]))' "$INSTANCE/instance.json" "$1"; }
+[[ "$(field marker)" == "verify-libghostty-vt-node" ]] && ok "made by launch.sh" || bad "instance.json was not made by launch.sh"
+[[ "$(field checkout)" == "$ROOT" ]] && ok "built from this checkout" || bad "built from $(field checkout), not $ROOT"
+
+head_now="$(git -C "$ROOT" rev-parse HEAD)"
+diff_now="$(git -C "$ROOT" diff HEAD | { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi; } | cut -d' ' -f1)"
+if [[ "$(field commit)" == "$head_now" && "$(field trackedDiffSha256)" == "$diff_now" ]]; then
+ ok "current: built from HEAD $head_now and the same uncommitted diff"
+else
+ bad "STALE: the checkout changed after launch (HEAD or tracked diff). Run cleanup.sh, then launch.sh with a new RUN_ROOT"
+fi
+
+[[ "$(field node)" == "$(node --version)" ]] && ok "node $(node --version) matches launch" \
+ || bad "node on PATH is $(node --version), launch used $(field node)"
+pinned="$(sed -n 's/^node = "\(.*\)"/v\1/p' "$ROOT/mise.toml")"
+[[ "$(node --version)" == "$pinned" ]] || warn "node $(node --version) is not the mise.toml pin $pinned"
+
+pkg_dir="$CONSUMER/node_modules/@coder/libghostty-vt-node"
+[[ -d "$pkg_dir" && ! -L "$pkg_dir" ]] && ok "package installed as a copy in the consumer" \
+ || bad "missing or symlinked $pkg_dir"
+
+addon="$(cd "$CONSUMER" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$pkg_dir" 2>&1)"
+case "$addon" in
+ "$pkg_dir"/prebuilds/*.node) ok "addon loads from the packed prebuild: ${addon#"$CONSUMER"/}" ;;
+ *) bad "node-gyp-build does not pick a packed prebuild: $addon" ;;
+esac
+
+info="$(cd "$CONSUMER" && node --input-type=module -e '
+ const { fileURLToPath } = await import("node:url");
+ const url = import.meta.resolve("@coder/libghostty-vt-node");
+ const { getNativeInfo } = await import(url);
+ console.log(JSON.stringify({ path: fileURLToPath(url), ...getNativeInfo() }));
+' 2>&1)"
+if [[ "$info" == "{"* ]]; then
+ ok "getNativeInfo: $info"
+ resolved="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).path)' "$info")"
+ case "$resolved" in
+ "$CONSUMER/node_modules/"*) ok "the bare import resolves inside the consumer" ;;
+ *) bad "the bare import resolves outside the consumer: $resolved" ;;
+ esac
+ version="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).packageVersion)' "$info")"
+ want="$(node -p 'require(process.argv[1]).version' "$ROOT/package.json")"
+ [[ "$version" == "$want" ]] && ok "packageVersion $version matches package.json" \
+ || bad "packageVersion $version, package.json has $want"
+else
+ bad "the package does not load in the consumer: $info"
+fi
+
+exit $fail
diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh
new file mode 100755
index 0000000..9360fb2
--- /dev/null
+++ b/.agents/skills/verify-libghostty-vt-node/scripts/drive.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+# Run one journey: an ES module read from stdin, run with node inside the
+# consumer project, so `import ... from "@coder/libghostty-vt-node"` loads the
+# installed package. Saves the script, stdout, stderr, and exit code as evidence.
+#
+# Usage: .agents/skills/verify-libghostty-vt-node/scripts/drive.sh RUN_ROOT NAME [node flags...] <<'JS'
+# import { createTerminal } from "@coder/libghostty-vt-node";
+# ...
+# JS
+# Writes: RUN_ROOT/evidence/NAME/{journey.mjs,command.txt,stdout.txt,stderr.txt,exit-code.txt}
+# Exit: the journey's exit code. A signal gives 128 + the signal number.
+set -uo pipefail
+
+RUN_ROOT="${1:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}"
+NAME="${2:?usage: drive.sh RUN_ROOT NAME [node flags...] < journey.mjs}"
+shift 2
+CONSUMER="$RUN_ROOT/instance/consumer"
+OUT="$RUN_ROOT/evidence/$NAME"
+[[ -f "$RUN_ROOT/instance/instance.json" ]] || { echo "error: no instance in $RUN_ROOT: run launch.sh" >&2; exit 2; }
+[[ "$NAME" =~ ^[a-z0-9][a-z0-9._-]*$ ]] || { echo "error: NAME must match [a-z0-9][a-z0-9._-]*" >&2; exit 2; }
+# One mkdir reserves the name, so two runs with one NAME cannot share evidence.
+mkdir -p "$RUN_ROOT/evidence" "$CONSUMER/journeys"
+mkdir "$OUT" 2>/dev/null || { echo "error: $OUT exists: pick a new NAME, evidence is never overwritten" >&2; exit 2; }
+cat > "$CONSUMER/journeys/$NAME.mjs"
+cp "$CONSUMER/journeys/$NAME.mjs" "$OUT/journey.mjs"
+echo "cd $CONSUMER && node $* journeys/$NAME.mjs # $(date -u +%Y-%m-%dT%H:%M:%SZ) $(node --version)" > "$OUT/command.txt"
+
+(cd "$CONSUMER" && node "$@" "journeys/$NAME.mjs") > "$OUT/stdout.txt" 2> "$OUT/stderr.txt"
+code=$?
+echo "$code" > "$OUT/exit-code.txt"
+cat "$OUT/stdout.txt"
+[[ -s "$OUT/stderr.txt" ]] && { echo "--- stderr" >&2; head -20 "$OUT/stderr.txt" >&2; }
+echo "--- exit $code, evidence in $OUT" >&2
+exit $code
diff --git a/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh
new file mode 100755
index 0000000..90d3d2c
--- /dev/null
+++ b/.agents/skills/verify-libghostty-vt-node/scripts/launch.sh
@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+# Build @coder/libghostty-vt-node from this checkout (with uncommitted changes),
+# pack it with a prebuild for this platform, and install the tarball into a new
+# consumer project, the way a user installs the package from npm.
+#
+# Usage: .agents/skills/verify-libghostty-vt-node/scripts/launch.sh [RUN_ROOT]
+# RUN_ROOT default: a new mktemp dir. The last line is `READY RUN_ROOT=
`.
+# Makes: RUN_ROOT/instance/ (tarball, consumer project, instance.json, launch.log)
+# RUN_ROOT/evidence/ (empty; drive.sh writes here, cleanup.sh keeps it)
+set -euo pipefail
+
+SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ROOT="$(cd "$SKILL_DIR/../../.." && pwd -P)"
+outside_checkout() {
+ case "$1/" in
+ "$ROOT"/*) echo "error: RUN_ROOT must be outside the checkout ($ROOT)." >&2; exit 2 ;;
+ esac
+}
+RUN_ROOT="${1:-}"
+if [[ -z "$RUN_ROOT" ]]; then
+ RUN_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/verify-libghostty-vt-node.XXXXXX")"
+elif [[ ! -e "$RUN_ROOT" && ! -L "$RUN_ROOT" ]]; then
+ parent="$(dirname -- "$RUN_ROOT")"
+ if [[ -d "$parent" ]]; then outside_checkout "$(cd "$parent" && pwd -P)/$(basename -- "$RUN_ROOT")"; fi
+ mkdir -p -- "$parent"
+ mkdir -m 700 -- "$RUN_ROOT"
+fi
+# The build installs the tarball from RUN_ROOT with npm, which runs package
+# scripts. Refuse a RUN_ROOT that another account can change or move: a
+# symlink, a directory owned by someone else or writable by group or others,
+# or one below any directory that another account owns (root excepted) or
+# that group or others can write without the sticky bit. Check the ancestors
+# of the path as given and of its physical path, and use the physical path
+# that this check returns: never resolve RUN_ROOT again after it.
+RUN_ROOT="$(node -e '
+ const fs = require("node:fs"), path = require("node:path");
+ const dir = process.argv[1], uid = process.getuid();
+ const fail = (why) => { console.error(`error: RUN_ROOT ${dir} ${why}`); process.exit(2); };
+ const st = fs.lstatSync(dir);
+ if (st.isSymbolicLink()) fail("is a symlink: pass the real path");
+ if (!st.isDirectory()) fail("is not a directory");
+ if (st.uid !== uid) fail("is not owned by you");
+ if (st.mode & 0o022) fail("is writable by group or others: chmod 700 it, or use a new path");
+ const real = fs.realpathSync(dir);
+ for (const start of [path.resolve(dir), real]) {
+ for (let up = path.dirname(start); ; up = path.dirname(up)) {
+ const a = fs.statSync(up);
+ if (a.uid !== uid && a.uid !== 0) fail(`is below ${up}, which another account owns`);
+ if ((a.mode & 0o022) && !(a.mode & 0o1000)) fail(`is below ${up}, which group or others can write`);
+ if (up === path.dirname(up)) break;
+ }
+ }
+ process.stdout.write(real);
+' "$RUN_ROOT")"
+outside_checkout "$RUN_ROOT"
+INSTANCE="$RUN_ROOT/instance"
+EVIDENCE="$RUN_ROOT/evidence"
+
+if [[ -e "$INSTANCE" ]]; then
+ echo "error: $INSTANCE exists. Run cleanup.sh $RUN_ROOT first, then use a new RUN_ROOT." >&2
+ exit 2
+fi
+# Evidence belongs to the launch that made it: never mix two launches in one RUN_ROOT.
+if [[ -d "$EVIDENCE" && -n "$(ls -A "$EVIDENCE")" ]]; then
+ echo "error: $EVIDENCE holds evidence from an earlier launch. Use a new RUN_ROOT." >&2
+ exit 2
+fi
+
+# One launch at a time per checkout: launch writes build/, dist/, and prebuilds/.
+mkdir -p "$ROOT/vendor"
+lock="$ROOT/vendor/.verify-launch.lock"
+if ! mkdir "$lock" 2>/dev/null; then
+ echo "error: another launch is building in this checkout ($lock)." >&2
+ echo "Wait for it. Remove the directory only if no launch.sh process runs." >&2
+ exit 2
+fi
+trap 'rmdir "$lock"' EXIT
+
+mkdir -p "$INSTANCE/consumer" "$EVIDENCE"
+# cleanup.sh removes only an instance with this marker, also after a failed launch.
+echo "made by launch.sh $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$INSTANCE/.verify-libghostty-vt-node"
+log="$INSTANCE/launch.log"
+step() { echo "== $*" | tee -a "$log"; }
+run() { "$@" >>"$log" 2>&1 || { echo "error: '$*' failed. Last lines of $log:" >&2; tail -30 "$log" >&2; exit 1; }; }
+sha_stdin() { if command -v sha256sum >/dev/null; then sha256sum; else shasum -a 256; fi | cut -d' ' -f1; }
+sha() { sha_stdin < "$1"; }
+# The checkout state that this launch builds. It must not change until the end.
+checkout_state() { echo "$(git -C "$ROOT" rev-parse HEAD) $(git -C "$ROOT" diff HEAD | sha_stdin)"; }
+state_before="$(checkout_state)"
+
+cd "$ROOT"
+# Reuse node_modules and libghostty-vt only when they were built from the
+# current inputs. Each stamp holds the hash of those inputs.
+deps_stamp="node_modules/.verify-libghostty-vt-node.sha256"
+deps_inputs="$(sha package-lock.json)"
+if [[ "$(cat "$deps_stamp" 2>/dev/null)" != "$deps_inputs" ]]; then
+ step "npm ci (node_modules is missing or older than package-lock.json)"
+ run npm ci
+ echo "$deps_inputs" > "$deps_stamp"
+fi
+lib_dir="${LIBGHOSTTY_VT_PREFIX:-$ROOT/vendor/libghostty-vt}"
+lib_stamp="$lib_dir/.verify-libghostty-vt-node.sha256"
+lib_inputs="$( { cat scripts/build-libghostty-vt.sh; env | grep -E '^(GHOSTTY_|LIBGHOSTTY_VT_)' | sort || true; } | sha_stdin)"
+if [[ ! -f "$lib_dir/lib/libghostty-vt.a" || "$(cat "$lib_stamp" 2>/dev/null)" != "$lib_inputs" ]]; then
+ step "npm run build:libghostty (missing, or built from other inputs: needs zig 0.15.2 and the network)"
+ run npm run build:libghostty
+ echo "$lib_inputs" > "$lib_stamp"
+fi
+# tsc never deletes output whose source is gone, and npm pack ships all of dist/.
+step "npm run build (from an empty dist/)"; rm -rf dist; run npm run build
+step "npm run build:prebuild"; run npm run build:prebuild
+step "npm pack"
+tarball="$(npm pack --silent --pack-destination "$INSTANCE" 2>>"$log" | tail -n 1)"
+[[ -f "$INSTANCE/$tarball" ]] || { echo "error: npm pack did not write $INSTANCE/$tarball" >&2; exit 1; }
+
+step "install $tarball into $INSTANCE/consumer"
+cat > "$INSTANCE/consumer/package.json" <<'JSON'
+{ "name": "verify-libghostty-vt-node-consumer", "private": true, "type": "module" }
+JSON
+(cd "$INSTANCE/consumer" && run npm install --no-audit --no-fund "$INSTANCE/$tarball")
+
+if [[ "$(checkout_state)" != "$state_before" ]]; then
+ echo "error: the checkout changed during the launch (HEAD or tracked diff). Run cleanup.sh, then launch again with a new RUN_ROOT." >&2
+ exit 1
+fi
+static_lib="${LIBGHOSTTY_VT_STATIC_LIB:-$lib_dir/lib/libghostty-vt.a}"
+addon="$(cd "$INSTANCE/consumer" && node -e 'process.stdout.write(require("node-gyp-build").path(process.argv[1]))' "$INSTANCE/consumer/node_modules/@coder/libghostty-vt-node")"
+INSTANCE="$INSTANCE" TARBALL="$tarball" TARBALL_SHA="$(sha "$INSTANCE/$tarball")" \
+LIB_INPUTS="$lib_inputs" STATIC_LIB="$static_lib" STATIC_LIB_SHA="$(sha "$static_lib")" \
+ADDON="${addon#"$INSTANCE/consumer/"}" ADDON_SHA="$(sha "$addon")" \
+COMMIT="${state_before% *}" DIFF_SHA="${state_before#* }" CHECKOUT="$ROOT" \
+node -e '
+ const fs = require("node:fs");
+ const e = process.env;
+ const info = {
+ marker: "verify-libghostty-vt-node",
+ checkout: e.CHECKOUT, commit: e.COMMIT, trackedDiffSha256: e.DIFF_SHA,
+ tarball: e.TARBALL, tarballSha256: e.TARBALL_SHA,
+ addon: e.ADDON, addonSha256: e.ADDON_SHA,
+ libghosttyVt: { staticLib: e.STATIC_LIB, staticLibSha256: e.STATIC_LIB_SHA, buildInputsSha256: e.LIB_INPUTS },
+ node: process.version, platform: process.platform, arch: process.arch,
+ createdAt: new Date().toISOString(),
+ };
+ fs.writeFileSync(e.INSTANCE + "/instance.json", JSON.stringify(info, null, 2) + "\n");
+'
+cp "$INSTANCE/instance.json" "$EVIDENCE/instance.json"
+echo "READY RUN_ROOT=$RUN_ROOT"