diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dd9482..1f9717e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,9 @@ All notable changes to `@openclaw/libterminal` will be documented in this file.
### Changed
+- Make Ghostty a pinned optional peer: renderer users install `ghostty-web@0.4.0`; stream/protocol-only installs no longer pull it in. Preserve embedded Worker assets.
+- Export the existing renderer-neutral `attachTerminalStream` from `/stream`, preserving `/browser`, with an xterm example and package/type/bundle footprint checks.
+
- Run full CodeQL scans daily instead of on every main push, preserving PR and manual analysis and protecting active scheduled scans from cancellation.
- Refresh Node.js types, tsdown, and WebSocket test tooling while preserving the dependency cooldown and supported Node.js versions. (#139, thanks @dependabot[bot])
diff --git a/README.md b/README.md
index 569e99a..1516971 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,15 @@ pnpm add @openclaw/libterminal
```
The Node.js adapters require Node.js `^22.18.0 || >=24.11.0`.
+For Ghostty rendering or Node-hosted Ghostty assets, install the pinned optional peer:
+
+```sh
+pnpm add ghostty-web@0.4.0
+```
+
+Stream and protocol consumers do not need a renderer. The self-contained
+`/worker-assets` export retains its embedded assets and also needs no peer.
+
Install the optional PTY driver when using `spawnLocalPty()`:
```sh
diff --git a/docs/usage.md b/docs/usage.md
index c050075..23220f0 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -15,6 +15,47 @@ independent copies, so changing one cannot corrupt other readers or stored repla
The exported testing helpers also copy recorded input and output bytes, including
sources that reuse a `Buffer` between chunks.
+## Keep an existing renderer or transport
+
+`attachTerminalStream` accepts an ordered byte iterator and a synchronous
+`write(Uint8Array)` target. Import it from `/stream` to avoid Ghostty types and
+renderer dependencies. The `/browser` export remains available.
+
+```ts
+import { attachTerminalStream } from "@openclaw/libterminal/stream";
+
+await attachTerminalStream(existingTerminal, output, abort.signal);
+```
+
+[`examples/xterm.ts`](https://github.com/openclaw/libterminal/blob/main/examples/xterm.ts) connects an actual xterm instance and
+owns its cancellation/disposal. Add `@xterm/xterm` and its CSS in your app. Keep
+input authorization and your existing WebSocket/SSH transport in the app.
+Attachment writes in order but does not wait for asynchronous rendering to finish;
+use the renderer's completion callback when you need that guarantee.
+
+`spawnLocalPty({ driver })` already accepts an injected `PtyDriver`. Applications
+using a prebuilt or platform-specific PTY package can adapt its `spawn` result to
+that interface without adding another driver to libterminal.
+
+## Compatibility and footprint checks
+
+| Surface | Automated check |
+| ---------------------- | ------------------------------------------------------------- |
+| Node 22, 24, 26 | Package, unit tests and installed consumer in CI |
+| Browser / Worker | Existing Ghostty asset parity and Chromium tests |
+| xterm | ANSI/UTF-8 rendering, cancellation and disposal in Chromium |
+| Renderer-free consumer | Empty install, strict TypeScript declarations and Vite bundle |
+
+`pnpm pack:check` verifies that installing libterminal alone does not install
+Ghostty or node-pty. It reports compressed archive bytes, unpacked package bytes
+and compressed root, protocol, stream and Worker consumer bundles separately.
+Each portable entry must remain below 12 KB gzip and contain no
+Ghostty/embedded-asset modules.
+
+The root archive still contains `/worker-assets` for existing consumers. Moving
+that synchronous public export into a separate package requires a coordinated
+breaking release; optional peers do not remove those bytes from the archive.
+
## Browser
Ghostty terminals default to read-only. The application owns authorization,
diff --git a/examples/xterm.ts b/examples/xterm.ts
new file mode 100644
index 0000000..4f0ba98
--- /dev/null
+++ b/examples/xterm.ts
@@ -0,0 +1,20 @@
+///
+import "@xterm/xterm/css/xterm.css";
+import { attachTerminalStream } from "@openclaw/libterminal/stream";
+import { Terminal } from "@xterm/xterm";
+
+// Keep your existing transport: only its ordered output iterator is required.
+export function mountXterm(parent: HTMLElement, output: AsyncIterable) {
+ const terminal = new Terminal({ cols: 80, rows: 24 });
+ terminal.open(parent);
+ const abort = new AbortController();
+ const done = attachTerminalStream(terminal, output, abort.signal);
+ return {
+ terminal,
+ done,
+ dispose() {
+ abort.abort();
+ terminal.dispose();
+ },
+ };
+}
diff --git a/package.json b/package.json
index ff5e5ca..6f76bdf 100644
--- a/package.json
+++ b/package.json
@@ -85,15 +85,14 @@
"test:coverage": "pnpm run test --coverage",
"typecheck": "pnpm run generate:worker-assets && tsc --noEmit && tsc -p tsconfig.portable.json --noEmit"
},
- "dependencies": {
- "ghostty-web": "0.4.0"
- },
"devDependencies": {
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.5",
"@types/ws": "^8.18.2",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitest/coverage-v8": "^5.0.5",
+ "@xterm/xterm": "6.0.0",
+ "ghostty-web": "0.4.0",
"node-pty": "^1.1.0",
"oxfmt": "^0.75.0",
"oxlint": "^1.89.0",
@@ -105,11 +104,15 @@
"ws": "^8.22.0"
},
"peerDependencies": {
+ "ghostty-web": "0.4.0",
"node-pty": ">=1.0.0"
},
"peerDependenciesMeta": {
"node-pty": {
"optional": true
+ },
+ "ghostty-web": {
+ "optional": true
}
},
"engines": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eb057f3..1727810 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,10 +7,6 @@ settings:
importers:
.:
- dependencies:
- ghostty-web:
- specifier: 0.4.0
- version: 0.4.0
devDependencies:
'@playwright/test':
specifier: ^1.64.1
@@ -27,6 +23,12 @@ importers:
'@vitest/coverage-v8':
specifier: ^5.0.5
version: 5.0.5(vitest@5.0.5)
+ '@xterm/xterm':
+ specifier: 6.0.0
+ version: 6.0.0
+ ghostty-web:
+ specifier: 0.4.0
+ version: 0.4.0
node-pty:
specifier: ^1.1.0
version: 1.1.0
@@ -642,6 +644,9 @@ packages:
'@vitest/spy@5.0.5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
+ '@xterm/xterm@6.0.0':
+ resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
+
'@yuku-core/binding-android-arm64@0.19.0':
resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==}
cpu: [arm64]
@@ -1495,6 +1500,8 @@ snapshots:
'@vitest/spy@5.0.5': {}
+ '@xterm/xterm@6.0.0': {}
+
'@yuku-core/binding-android-arm64@0.19.0':
optional: true
diff --git a/scripts/browser-smoke-server.mjs b/scripts/browser-smoke-server.mjs
index b708118..47df7bd 100644
--- a/scripts/browser-smoke-server.mjs
+++ b/scripts/browser-smoke-server.mjs
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
+import { build } from "vite";
import { readGhosttyAsset } from "../dist/node.js";
import { readGhosttyWorkerAsset } from "../dist/worker-assets.js";
@@ -66,6 +67,28 @@ function html(assets) {