Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .agents/skills/verify-libghostty-vt-node/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
---
name: verify-libghostty-vt-node
description: "Drive @coder/libghostty-vt-node the way a user does: build and pack the package from this checkout, install the tarball into a scratch consumer project, run Node journeys that import the package by name, and keep stdout, stderr, and exit codes as evidence. Use to prove a change to the public API (createTerminal, feed, snapshot, resize, dispose, getNativeInfo), the native addon, the loader, or the package contents works for a package user."
---

# Verify libghostty-vt-node as a package user

The user surface is a Node library. A user runs `npm install @coder/libghostty-vt-node` and gets a prebuilt addon for the platform. Then the user imports the package by name and calls `createTerminal`, `feed`, `snapshot`, `resize`, `getVisibleText`, `formatPlain`, `formatHtml`, `dispose`, and `getNativeInfo`. This skill reproduces that path from the current checkout, uncommitted changes included. It does not import `src/` or `dist/` from the checkout.

Other surfaces are out of scope here. The release workflows (`.github/workflows/publish.yml`, `mise run publish-dry-run`) publish to npm. Do not drive them for verification. The worker-thread and dispose lifecycle has a formal check in the `verify-native-lifecycle` skill.

All commands run from the repo root. Set this first, and again in each new shell (agent tool calls often start a new shell, so set `S`, `RUN_ROOT`, and `PATH` in each call):

```sh
S=.agents/skills/verify-libghostty-vt-node/scripts
```

## Requirements

- The toolchain that `mise.toml` pins: Node `26.12.1` and Zig `0.15.2` on `PATH`. `mise install` installs them. If `mise exec` fails on your host, put the two install directories on `PATH` directly, for example `~/.local/share/mise/installs/node/26.12.1/bin` and `~/.local/share/mise/installs/zig/0.15.2`.
- A C++17 compiler and Python for `node-gyp`.
- A checkout that mise trusts. In an untrusted copy, a mise shim that a build step calls fails with `Config files in .../mise.toml are not trusted`, and `launch.sh` exits `1`.
- The network on the first launch: `npm ci`, the Ghostty clone in `npm run build:libghostty`, and the dependency install in the consumer project.

## Launch

```sh
$S/launch.sh # makes a new RUN_ROOT with mktemp
$S/launch.sh "$RUN_ROOT" # or uses a directory that you name, outside the checkout
```

`launch.sh` runs in the checkout: `npm ci` (when `node_modules/` was not built from the current `package-lock.json`), `npm run build:libghostty` (when `vendor/libghostty-vt/` was not built from the current `scripts/build-libghostty-vt.sh` and `GHOSTTY_*` / `LIBGHOSTTY_VT_*` variables), `npm run build` from an empty `dist/`, `npm run build:prebuild`, and `npm pack`. A stamp file with the hash of those inputs records each reuse decision, and `RUN_ROOT/instance/launch.log` lists the steps that ran. It records the commit and tracked diff before the build, and it fails if they change before the end. Then it installs the tarball into `RUN_ROOT/instance/consumer/` with `npm install`. It is ready when the last line is `READY RUN_ROOT=<dir>`. Set the shell variable `RUN_ROOT` to that path. It can differ from the path you gave, because `launch.sh` resolves symlinks. A warm launch takes about 15 seconds. The first one also builds libghostty-vt.

A `RUN_ROOT` that you name must be a real directory (not a symlink) that only you can write. Every directory above it must be owned by you or root, and group or others must not be able to write it, unless it has the sticky bit, like `/tmp`. `launch.sh` creates a missing one with mode `700`. It refuses anything else, because `npm install` runs package scripts from the tarball in `RUN_ROOT`.

`launch.sh` writes `build/`, `dist/`, and `prebuilds/` in the checkout, so only one launch can run per checkout. A second launch exits `2` and names the lock directory `vendor/.verify-launch.lock`. After launch, each `RUN_ROOT` is independent: many runs can drive their own instances at the same time.

There is no server to tear down. Each journey is a short Node process. Teardown is `cleanup.sh`.

## Doctor

```sh
$S/doctor.sh "$RUN_ROOT"
```

It is read-only. Exit `0` means that the instance is worth driving. Each line starts with `ok`, `WARN`, or `FAIL`. It checks that:

1. `instance.json` exists, `launch.sh` made it, and it names this checkout.
2. The checkout has the same `HEAD` and the same tracked diff as at launch. Otherwise it prints `STALE`: run `cleanup.sh`, then `launch.sh` with a new `RUN_ROOT`.
3. `node` on `PATH` is the version that launch used. It warns when that is not the `mise.toml` pin.
4. The package is a real copy in the consumer's `node_modules`, not a symlink to the checkout.
5. `node-gyp-build` picks the packed prebuild (`prebuilds/<platform>-<arch>/@coder+libghostty-vt-node.node`).
6. The bare import resolves inside the consumer, and `getNativeInfo().packageVersion` matches `package.json`.

Run it first, and again whenever a result looks wrong.

## Drive

A journey is an ES module that `drive.sh` reads from stdin and runs with `node` in the consumer project. It imports `@coder/libghostty-vt-node` by name and asserts its own result with `node:assert/strict`, so its exit code is the verdict.

```sh
$S/drive.sh "$RUN_ROOT" my-journey <<'JS'
import assert from "node:assert/strict";
import { createTerminal } from "@coder/libghostty-vt-node";
const t = createTerminal({ cols: 20, rows: 2 });
t.feed("hi");
console.log(JSON.stringify(t.getVisibleText()));
assert.equal(t.getVisibleText(), "hi\n");
t.dispose();
JS
```

Put node flags after the name, for example `$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc`. `drive.sh` prints the journey's stdout, then `--- exit N, evidence in <dir>` on stderr, and it exits with the journey's code. When stdout and stderr share one stream, the exit line can show up out of order: read `exit-code.txt` for the verdict. A crash gives `128 + signal`: `134` for SIGABRT, `139` for SIGSEGV.

The map in [`features/README.md`](features/README.md) has a literal journey for each sub-feature. Read the index, then use the matching feature file.

## Evidence

Each journey writes `RUN_ROOT/evidence/<name>/`: `journey.mjs`, `command.txt` (directory, node flags, time, node version), `stdout.txt`, `stderr.txt`, and `exit-code.txt`. `RUN_ROOT/evidence/instance.json` names the commit, the tracked-diff hash, and the sha256 of the tarball, of the loaded addon, and of the libghostty-vt static library with the hash of its build inputs. One `RUN_ROOT` holds the evidence of one launch: `launch.sh` refuses a `RUN_ROOT` whose `evidence/` is not empty. `drive.sh` refuses to overwrite a journey's directory.

Proof standards:

- Drive the installed package through its public exports. Do not import `src/`, `dist/` in the checkout, the `.node` file, or the native `createTerminal` directly.
- A proof shows the action and the resulting state. Print what the journey saw (text, snapshot fields, error type and message), and assert it.
- Check side effects that a user can see: the files in the installed package (`info-package-contents`), and the addon file that `doctor.sh` reports.
- No mocks. The package has no external system to stand in for.
- A failed journey is a result. Report it with its stderr. Do not change the assertions to make it pass.

## Cleanup

```sh
$S/cleanup.sh "$RUN_ROOT"
```

It removes `RUN_ROOT/instance/` (the tarball, the consumer project, and the `no-addon` copy that `info-load-error` makes). It refuses an `instance/` without the `.verify-libghostty-vt-node` marker that `launch.sh` writes first, so it also cleans up after a failed launch. It keeps `RUN_ROOT/evidence/` and lists it. It does not touch the checkout's `build/`, `dist/`, or `prebuilds/`: those are normal build outputs, and git ignores them. Run cleanup after every run, the failed ones too. Remove `RUN_ROOT` yourself when you no longer need the evidence.

## Gotchas

- `launch.sh` packs whatever is in `prebuilds/`, including directories for other platforms from an earlier `mise run assemble-prebuilds`. `doctor.sh` checks that this platform's prebuild is the one that loads.
- `doctor.sh` hashes only tracked changes. A new untracked source file does not make the instance `STALE`. Relaunch after you add one.
- `RUN_ROOT` must be outside the checkout, so the instance never shows up in `git status` and no import can resolve to the checkout's own files. `launch.sh` refuses a `RUN_ROOT` inside it.
41 changes: 41 additions & 0 deletions .agents/skills/verify-libghostty-vt-node/features/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# libghostty-vt-node verification map

This directory is the maintained source for verifying what a user of `@coder/libghostty-vt-node` sees. Read this index before you drive the package. Then use the matching feature file as the recipe.

## Baseline preconditions

- Run every command from the repo root.
- `RUN_ROOT` is a directory outside the checkout that `launch.sh` made for this run. Never drive a `RUN_ROOT` that another run made.
- `doctor.sh "$RUN_ROOT"` exits `0`. Run it again after any change to the checkout: it fails with `STALE` when the build no longer matches the code.
- `S=.agents/skills/verify-libghostty-vt-node/scripts`.

## Driving conventions

- Each journey is an ES module that `drive.sh` runs in the consumer project. It imports `@coder/libghostty-vt-node` by name, the way a user does.
- Each journey asserts its own result with `node:assert/strict`, so the exit code is the verdict. It also prints what it saw, so stdout shows the result.
- Copy each command literally. Give each run a new journey name: `drive.sh` refuses to overwrite evidence. Add a suffix such as `-2` for a second run.
- Each journey creates its own terminals and disposes them. No journey depends on another one.

## Proof and skip reporting

- A journey's proof is its directory in `$RUN_ROOT/evidence/`: `journey.mjs`, `command.txt`, `stdout.txt`, `stderr.txt`, and `exit-code.txt`. Also keep `$RUN_ROOT/evidence/instance.json`, which names the commit and tarball.
- Report the journey name and its exit code with every claim.
- Report a failed journey with its stderr. Do not change a journey's assertions to make it pass.
- When a precondition fails, report the command and the unmet precondition. Do not report a skipped sub-feature as verified through another one.

## Feature entry contract

Each feature file starts with an H1 title and one paragraph that describes what the user sees. It then has exactly four H2 sections in this order.

1. `Sub-features` lists short IDs, one line for each behavior. Each ID is also the journey name.
2. `How to get to it (user POV)` lists each public API call or install step that reaches the feature.
3. `Driving it with drive.sh` starts with `Preconditions:` and pairs each action with a literal command and its observable result.
4. `Gotchas` lists traps that can waste or invalidate a run.

## Features

- [Feed bytes and read text](./terminal-text.md) covers `feed` input kinds, escape handling, split UTF-8, and the debug formatters.
- [Structured snapshot](./snapshot.md) covers size, cursor, styled cells, scrollback, and the alternate screen.
- [Resize](./resize.md) covers new sizes, reflow, and invalid sizes.
- [Lifecycle and errors](./lifecycle.md) covers `dispose`, garbage collection, invalid input, and worker threads.
- [Install, load, and native info](./native-info.md) covers the installed package, `getNativeInfo`, and the missing-addon error.
122 changes: 122 additions & 0 deletions .agents/skills/verify-libghostty-vt-node/features/lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Lifecycle and errors

A user creates terminals, disposes them, and lets the garbage collector free the ones they drop. Bad options and use after `dispose()` throw clear errors. A user can also load the package in `worker_threads`.

## Sub-features

- `lifecycle-dispose` makes `dispose()` idempotent, and every method throws after it.
- `lifecycle-gc` lets V8 collect dropped terminals, disposed or not, without a crash.
- `lifecycle-invalid` rejects bad `createTerminal` options and bad `feed` data.
- `lifecycle-workers` creates terminals in the main thread after a Worker loaded the package.

## How to get to it (user POV)

- `term.dispose()`, then any method on `term`.
- Drop every reference to a terminal and let V8 collect it.
- `createTerminal(options)` with wrong options, and `term.feed(data)` with a wrong type.
- `new Worker(...)` that imports `@coder/libghostty-vt-node`.

## Driving it with drive.sh

Preconditions:

- `launch.sh` printed `READY RUN_ROOT=...`, and `doctor.sh "$RUN_ROOT"` exits `0`.
- `S=.agents/skills/verify-libghostty-vt-node/scripts`, run from the repo root.

- **Dispose.** Dispose twice, then call each method. Run `lifecycle-dispose`. Exit `0`. Stdout lists 6 methods, each with `GhosttyVtTerminal has been disposed`.

```sh
$S/drive.sh "$RUN_ROOT" lifecycle-dispose <<'JS'
import assert from "node:assert/strict";
import { createTerminal } from "@coder/libghostty-vt-node";
const t = createTerminal({ cols: 10, rows: 2 });
t.dispose(); t.dispose();
const calls = { feed: () => t.feed("x"), resize: () => t.resize(5, 5), snapshot: () => t.snapshot(),
getVisibleText: () => t.getVisibleText(), formatPlain: () => t.formatPlain(), formatHtml: () => t.formatHtml() };
for (const [name, call] of Object.entries(calls)) {
assert.throws(call, /GhosttyVtTerminal has been disposed/);
console.log(name, "throws: GhosttyVtTerminal has been disposed");
}
JS
```

- **Garbage collection.** Create 200 terminals in a function, dispose every other one, return, and force GC. Run `lifecycle-gc` with `--expose-gc`. Exit `0`, and stdout is `collected 100 disposed and 100 open terminals`. Each count proves that V8 collected the JS `Terminal` objects of that kind and that the process survived. JS cannot see the native `TerminalWrap` finalizer or the freed handle, so this journey does not prove that the handles were freed. The `verify-native-lifecycle` skill covers that.

```sh
$S/drive.sh "$RUN_ROOT" lifecycle-gc --expose-gc <<'JS'
import { createTerminal } from "@coder/libghostty-vt-node";
const freed = new FinalizationRegistry((kind) => { freed.counts[kind]++; });
freed.counts = { disposed: 0, open: 0 };
// Create in a function, so no local variable keeps a terminal alive.
function createAndDrop(n) {
for (let i = 0; i < n; i++) {
const t = createTerminal({ cols: 20, rows: 5 });
t.feed(`t${i}`);
const kind = i % 2 === 0 ? "disposed" : "open";
if (kind === "disposed") t.dispose();
freed.register(t, kind);
Comment thread
ThomasK33 marked this conversation as resolved.
}
}
createAndDrop(200);
for (let i = 0; i < 20 && freed.counts.disposed + freed.counts.open < 200; i++) {
globalThis.gc(); await new Promise((r) => setTimeout(r, 10));
}
console.log(`collected ${freed.counts.disposed} disposed and ${freed.counts.open} open terminals`);
if (freed.counts.disposed !== 100 || freed.counts.open !== 100) process.exit(1);
JS
```

- **Invalid input.** Run `lifecycle-invalid`. Exit `0`. Stdout shows each call with its error: `null` options give `AssertionError: options must not be null`, `cols: 0` gives `TypeError: cols must be a positive integer`, `scrollbackLimit: -1` gives `TypeError: scrollbackLimit must be a non-negative integer`, `cols: 70000` gives `RangeError: cols must be a positive 16-bit integer`, and `feed(123)` and `feed(new Uint16Array(2))` give `TypeError: feed data must be a string, Buffer, or Uint8Array`.

```sh
$S/drive.sh "$RUN_ROOT" lifecycle-invalid <<'JS'
import assert from "node:assert/strict";
import { createTerminal } from "@coder/libghostty-vt-node";
const t = createTerminal({ cols: 10, rows: 2 });
const cases = [
["createTerminal(null)", () => createTerminal(null), "AssertionError", /options must not be null/],
["createTerminal({cols:0})", () => createTerminal({ cols: 0, rows: 2 }), "TypeError", /cols must be a positive integer/],
["createTerminal({scrollbackLimit:-1})", () => createTerminal({ cols: 2, rows: 2, scrollbackLimit: -1 }), "TypeError", /scrollbackLimit must be a non-negative integer/],
["createTerminal({cols:70000})", () => createTerminal({ cols: 70000, rows: 2 }), "RangeError", /positive 16-bit integer/],
["feed(123)", () => t.feed(123), "TypeError", /string, Buffer, or Uint8Array/],
["feed(Uint16Array)", () => t.feed(new Uint16Array(2)), "TypeError", /string, Buffer, or Uint8Array/],
];
for (const [label, call, type, msg] of cases) {
try { call(); assert.fail(`${label} did not throw`); }
catch (e) { console.log(label, "->", e.constructor.name + ":", e.message);
assert.equal(e.constructor.name, type); assert.match(e.message, msg); }
}
t.feed("still usable"); assert.equal(t.getVisibleText(), "still usab\nle");
t.dispose();
JS
```

- **Worker threads.** The main thread creates a terminal, a Worker loads the package, and the main thread creates another terminal. Run `lifecycle-workers`. Exit `0`, and stdout ends with `main: created a terminal after the worker loaded`. A failure kills the process with a signal, so `drive.sh` exits `134` (SIGABRT, stderr has `Cannot create a handle without a HandleScope`) or `139` (SIGSEGV). A hang exits `1` after 20 seconds with `timeout:` on stderr. That is a regression of finding F1 in the `verify-native-lifecycle` skill: report it, and do not change the journey to make it pass.

```sh
$S/drive.sh "$RUN_ROOT" lifecycle-workers <<'JS'
import { Worker } from "node:worker_threads";
import { once } from "node:events";
import { createTerminal } from "@coder/libghostty-vt-node";
// A hang is a failure too: end the journey with evidence instead of waiting forever.
setTimeout(() => { console.error("timeout: the worker journey did not finish in 20 s"); process.exit(1); }, 20_000).unref();
createTerminal({ cols: 10, rows: 2 }).dispose();
console.log("main: created a terminal");
const w = new Worker(`import("@coder/libghostty-vt-node").then((m) => {
m.createTerminal({ cols: 10, rows: 2 }).dispose();
import("node:worker_threads").then(({ parentPort }) => parentPort.postMessage("loaded"));
});`, { eval: true });
await once(w, "message");
Comment thread
ThomasK33 marked this conversation as resolved.
console.log("worker: loaded the package and created a terminal");
createTerminal({ cols: 10, rows: 2 }).dispose();
console.log("main: created a terminal after the worker loaded");
await w.terminate();
JS
```

## Gotchas

- `lifecycle-workers` checks one order of events. The `verify-native-lifecycle` skill checks every order within its bounds, and `test/workers.test.ts` covers an exited worker and two workers.
- `lifecycle-gc` needs `--expose-gc` after the journey name. Without it, `globalThis.gc` is undefined and the journey throws.
- GC timing varies. The journey retries 20 times. A count below 100 for either kind after that is a failure to report, not a reason to raise the retry count. Create terminals inside a function as the journey does: a terminal held by a top-level variable or array is never collected.
- A Worker's `eval` code resolves `@coder/libghostty-vt-node` from the consumer project, because `drive.sh` runs node in that directory.
Loading
Loading