diff --git a/WRITING.md b/WRITING.md
index 576a1ebb..2a13cd89 100644
--- a/WRITING.md
+++ b/WRITING.md
@@ -105,6 +105,19 @@ Record its commands, source revision, result, and content hash in the review.
Tests that add a hidden prelude or execute a larger source file do not verify
the copied example. Preserve collected examples when changing formatting.
+Repeat a complete capture example with its native tools and tmux on `PATH`:
+
+```console
+$ python3 scripts/check-capture-prose.py \
+ --port kotlin \
+ --output-dir /tmp/capture-kotlin-proof
+```
+
+The output directory must be new. The runner extracts the displayed files,
+checks their recorded hashes, executes the displayed setup, and saves logs
+and a result. Dependency downloads and native builds are separate from the
+routine site tests. A changed hash needs a new native run before review.
+
Put explanatory comments on separate lines above the code they describe.
Wrap example comments at 80 columns, including indentation. Put long source
links and attribution in prose outside the code block.
diff --git a/scripts/check-capture-prose.py b/scripts/check-capture-prose.py
new file mode 100644
index 00000000..450b2f3e
--- /dev/null
+++ b/scripts/check-capture-prose.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Run a complete capture example exactly as displayed, including its setup.
+
+Use --port to choose a language and --output-dir for a new evidence directory.
+The selected language's native tools, Git, and tmux must already be on PATH.
+This downloads and builds dependencies; it belongs outside routine site tests.
+"""
+
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import subprocess
+import time
+
+
+def main():
+ repo = Path(__file__).resolve().parent.parent
+ manifest = json.loads((repo / 'site/test/fixtures/capture-examples.json').read_text())
+ examples = {item['port']: item for item in manifest['examples']}
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--port', required=True, choices=examples)
+ parser.add_argument('--output-dir', required=True, type=Path)
+ args = parser.parse_args()
+ example = examples[args.port]
+ page = repo / 'site/src/content/docs' / (example['page'] + '.md')
+ content = page.read_text()
+ blocks = list(re.finditer(r'^```(\S+)([^\n]*)\n(.*?)^```', content, re.M | re.S))
+ files = {}
+ for item in example['files']:
+ name = item['name']
+ path = Path(item.get('path', name))
+ if path.is_absolute() or '..' in path.parts:
+ raise ValueError(f'Invalid example filename: {name}')
+ matches = [block[3] for block in blocks if f'title="{name}"' in block[2]]
+ if len(matches) != 1:
+ raise ValueError(f'Expected one displayed file: {name}')
+ code = matches[0]
+ if hashlib.sha256(code.encode()).hexdigest() != item['sha256']:
+ raise ValueError(f'Changed example bytes: {name}; review its verification record')
+ files[str(path)] = code
+ commands = [re.sub(r'^\$ ', '', block[3], flags=re.M).strip()
+ for block in blocks if block[1] == 'console']
+ if not commands or commands != example['shellRecipe']:
+ raise ValueError('Setup commands differ from the verification record')
+
+ output = args.output_dir.resolve()
+ output.mkdir(parents=True, exist_ok=False)
+ for name, code in files.items():
+ target = output / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(code)
+ env = dict(os.environ)
+ env.pop('TMUX', None)
+ env.pop('TMUX_PANE', None)
+ results = []
+ for index, command in enumerate(commands):
+ start = time.monotonic()
+ log = output / f'run-{index + 1}.log'
+ print(f'Running {args.port}; log: {log}', flush=True)
+ with log.open('w') as stream:
+ result = subprocess.run(['sh', '-eu', '-c', command], cwd=output,
+ env=env, stdout=stream, stderr=subprocess.STDOUT)
+ results.append({'command': command, 'exit': result.returncode,
+ 'seconds': round(time.monotonic() - start, 3)})
+ if result.returncode:
+ break
+ passed = all(row['exit'] == 0 for row in results)
+ passed = passed and 'libtmux capture ready' in log.read_text().splitlines()
+ report = {'port': args.port, 'sourceRevision': example['sourceRevision'],
+ 'pageSha256': hashlib.sha256(page.read_bytes()).hexdigest(),
+ 'files': example['files'], 'runs': results, 'passed': passed,
+ 'scope': 'Exact displayed program and setup; native execution on this host.'}
+ (output / 'result.json').write_text(json.dumps(report, indent=2) + '\n')
+ print(json.dumps(report, indent=2))
+ return 0 if passed else 1
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/site/src/content/docs/examples/capture-pane-output.md b/site/src/content/docs/examples/capture-pane-output.md
index f5c7c2a4..8dd341a2 100644
--- a/site/src/content/docs/examples/capture-pane-output.md
+++ b/site/src/content/docs/examples/capture-pane-output.md
@@ -88,9 +88,14 @@ Complete programs with imports, setup, and cleanup:
[Go](/go/latest/examples/capture-pane-output/) ·
[Rust](/rs/latest/examples/capture-pane-output/) ·
[Java](/java/latest/examples/capture-pane-output/) ·
+[Kotlin](/kotlin/latest/examples/capture-pane-output/) ·
+[Scala](/scala/latest/examples/capture-pane-output/) ·
[.NET](/dotnet/latest/examples/capture-pane-output/) ·
+[F#](/fsharp/latest/examples/capture-pane-output/) ·
[C++](/cxx/latest/examples/capture-pane-output/) ·
-[Swift](/swift/latest/examples/capture-pane-output/)
+[Swift](/swift/latest/examples/capture-pane-output/) ·
+[Ruby](/ruby/latest/examples/capture-pane-output/) ·
+[Lua](/lua/latest/examples/capture-pane-output/)
diff --git a/site/src/content/docs/ports/cxx/examples/capture-pane-output.md b/site/src/content/docs/ports/cxx/examples/capture-pane-output.md
index 7d74d631..ec60ae7b 100644
--- a/site/src/content/docs/ports/cxx/examples/capture-pane-output.md
+++ b/site/src/content/docs/ports/cxx/examples/capture-pane-output.md
@@ -107,7 +107,8 @@ explains why sending and waiting are separate operations.
Use an empty directory. The commands pin the library
revision used to verify the program.
-Save the program and build file using the displayed names. Use CMake
+No separate header is needed for this single-file executable. Save the program
+and build file using the displayed names. Use CMake
3.25 or newer, Ninja, and Clang 18 with libc++ 18 on Linux. The public testing
library supplies the private server's lifetime management; it is linked explicitly below.
diff --git a/site/src/content/docs/ports/fsharp/examples.md b/site/src/content/docs/ports/fsharp/examples.md
index 7eb94ba6..dcac1048 100644
--- a/site/src/content/docs/ports/fsharp/examples.md
+++ b/site/src/content/docs/ports/fsharp/examples.md
@@ -7,6 +7,10 @@ sidebar:
group: Examples
---
+Start with [Capture pane output](./capture-pane-output/) for a standalone program with imports, a project file, and private-server cleanup.
+
+## Filter a snapshot
+
This complete program creates an isolated tmux server, captures its panes, and applies a portable filter. Both owned scopes close when the task completes. Follow the [package quickstart](../guides/quickstart/) to create an F# project and install LibTmux.FSharp, then use this as Program.fs.
```fsharp file="examples/LibTmux.FSharp.Quickstart/Program.fs"
diff --git a/site/src/content/docs/ports/fsharp/examples/capture-pane-output.md b/site/src/content/docs/ports/fsharp/examples/capture-pane-output.md
new file mode 100644
index 00000000..93173af5
--- /dev/null
+++ b/site/src/content/docs/ports/fsharp/examples/capture-pane-output.md
@@ -0,0 +1,138 @@
+---
+port: fsharp
+route: examples/capture-pane-output
+title: Capture pane output
+description: Run a complete F# program that captures output on an isolated tmux server.
+sidebar:
+ label: Capture pane output
+ group: Examples
+ order: 3
+tableOfContents: true
+---
+
+This complete F# program starts a private tmux server, sends a command,
+and captures the line it prints. It includes imports, setup, and cleanup.
+You need tmux and a Unix environment; no existing session is required.
+
+## Read what's on screen
+
+The leading newline puts the output on a fresh row. Matching the whole line
+avoids mistaking the echoed command for its output.
+
+```fsharp title="Program.fs"
+open System
+open System.Collections.Generic
+open System.Diagnostics
+open System.IO
+open System.Threading
+open System.Threading.Tasks
+open LibTmux
+open LibTmux.FSharp
+
+let capture () = task {
+ let directory = Path.Combine("/tmp/libtmux-dotnet-dev", Guid.NewGuid().ToString("N"))
+ Directory.CreateDirectory(directory) |> ignore
+ let errors = ResizeArray()
+ let mutable owned: OwnedServerScope option = None
+ let mutable stopped = false
+ try
+ use timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10.0))
+ let token = timeout.Token
+ let environment = Dictionary()
+ for name in [ "TMUX"; "TMUX_PANE"; "ENV"; "BASH_ENV" ] do
+ environment[name] <- null
+ let! scope = LibTmux.Server.CreateOwnedAsync(
+ ServerConnectionOptions(
+ SocketPath = Path.Combine(directory, "tmux.sock"),
+ ConfigurationFile = "/dev/null",
+ ChildEnvironment = environment), token)
+ owned <- Some scope
+ let! session = scope.Value.CreateSessionAsync(
+ NewSessionRequest(Name = "capture", Command = "/bin/sh"), token)
+ let! panes = session.GetPanesAsync(token)
+ let pane = panes[0]
+ do! pane |> Pane.sendKeys token (SendKeysRequest(
+ Text = "printf '\\nlibtmux capture ready\\n'", Literal = true, Enter = true))
+
+ let elapsed = Stopwatch.StartNew()
+ let mutable captured = false
+ while not captured && elapsed.Elapsed < TimeSpan.FromSeconds(5.0) do
+ let! lines = pane |> Pane.capture token (CapturePaneRequest())
+ captured <- Seq.contains "libtmux capture ready" lines
+ if not captured then do! Task.Delay(25, token)
+ if not captured then
+ raise (TimeoutException("Output did not arrive within five seconds"))
+ printfn "libtmux capture ready"
+ with error -> errors.Add(error)
+
+ // Keep the endpoint available for inspection if stopping the server fails.
+ match owned with
+ | Some scope ->
+ try
+ do! scope.DisposeAsync().AsTask()
+ stopped <- true
+ with error -> errors.Add(error)
+ | None -> stopped <- not (File.Exists(Path.Combine(directory, "tmux.sock")))
+ if stopped then
+ try Directory.Delete(directory, true)
+ with error -> errors.Add(error)
+ if errors.Count > 0 then raise (AggregateException(errors))
+}
+
+[]
+let main _ =
+ try
+ capture().GetAwaiter().GetResult()
+ 0
+ with error ->
+ eprintfn "%O" error
+ 1
+```
+
+
+
+## Wait for output or completion
+
+`Pane.sendKeys` and `Pane.capture` return tasks. The program stops its owned server after either success or failure. It reports cleanup errors alongside the original error and keeps the socket directory if stopping fails.
+
+Capture reads screen state and scrollback, so output that has scrolled away
+may be absent. The program prints `libtmux capture ready` when its check passes
+and exits unsuccessfully if an operation fails.
+
+## Setup and run
+
+Use an empty directory and save the files using the displayed names. You need
+.NET SDK 10.0.302. Restore into a project-local cache from NuGet so FSharp.Core matches the library’s lockfile.
+
+Save `Capture.fsproj` beside `Program.fs`.
+
+```xml title="Capture.fsproj"
+
+
+ Exe
+ net10.0
+
+
+
+
+
+
+```
+
+The commands pin the library revision used to run this program.
+
+```console
+$ git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&
+ git -C libtmux-source checkout 661287848a6cfb407f37114b25e8249a29f99e3f &&
+ dotnet build Capture.fsproj --maxcpucount:1 \
+ -p:DisableImplicitLibraryPacksFolder=true \
+ -p:RestorePackagesPath="$PWD/.packages" &&
+ dotnet run --project Capture.fsproj --no-build
+```
+
+
+
+## Where this comes from
+
+The displayed files were compiled or loaded with their native tools and run
+on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
diff --git a/site/src/content/docs/ports/kotlin/examples.md b/site/src/content/docs/ports/kotlin/examples.md
index 7a2537e7..61031e3f 100644
--- a/site/src/content/docs/ports/kotlin/examples.md
+++ b/site/src/content/docs/ports/kotlin/examples.md
@@ -7,6 +7,10 @@ sidebar:
group: Examples
---
+Start with [Capture pane output](./capture-pane-output/) for a standalone program with imports, Gradle files, and private-server cleanup.
+
+## Read a Flow
+
Read pushed pane output as a coroutine Flow and cancel a pending wait. The complete program belongs to the Java repository's examples module; that module also supplies `WatchPaneOutput`, and its test starts an isolated tmux server before calling this program's `main`. Run `./gradlew :examples:test` from that repository.
```kotlin file="examples/src/main/kotlin/io/github/libtmux/examples/WatchWithFlow.kt"
diff --git a/site/src/content/docs/ports/kotlin/examples/capture-pane-output.md b/site/src/content/docs/ports/kotlin/examples/capture-pane-output.md
new file mode 100644
index 00000000..7a7b80f4
--- /dev/null
+++ b/site/src/content/docs/ports/kotlin/examples/capture-pane-output.md
@@ -0,0 +1,151 @@
+---
+port: kotlin
+route: examples/capture-pane-output
+title: Capture pane output
+description: Run a complete Kotlin program that captures output on an isolated tmux server.
+sidebar:
+ label: Capture pane output
+ group: Examples
+ order: 3
+tableOfContents: true
+---
+
+This complete Kotlin program starts a private tmux server, sends a command,
+and captures the line it prints. It includes imports, setup, and cleanup.
+You need tmux and a Unix environment; no existing session is required.
+
+## Read what's on screen
+
+The leading newline puts the output on a fresh row. Matching the whole line
+avoids mistaking the echoed command for its output.
+
+```kotlin title="Capture.kt"
+import io.github.libtmux.ServerConfig
+import io.github.libtmux.ServerEndpoint
+import io.github.libtmux.kotlin.capture
+import io.github.libtmux.kotlin.killServer
+import io.github.libtmux.kotlin.newSession
+import io.github.libtmux.kotlin.sendLine
+import io.github.libtmux.kotlin.withServer
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.Duration
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+
+fun main() = runBlocking {
+ val root = Files.createDirectories(Path.of("/tmp/libtmux-java-dev"))
+ val directory = Files.createTempDirectory(root, "capture-")
+ val socket = directory.resolve("tmux.sock")
+ val config = ServerConfig.builder()
+ .endpoint(ServerEndpoint.socketPath(socket))
+ .configFile(Path.of("/dev/null"))
+ .defaultTimeout(Duration.ofSeconds(1))
+ .build()
+ var failure: Throwable? = null
+ try {
+ withServer(config) { server ->
+ try {
+ val session = server.newSession {
+ name = "capture"
+ running("/bin/sh")
+ env("ENV", "/dev/null")
+ }
+ val pane = session.windows.first().panes.first()
+ pane.sendLine("printf '\\nlibtmux capture ready\\n'")
+ withTimeout(5_000) {
+ while (!pane.capture().contains("libtmux capture ready")) {
+ delay(25)
+ }
+ }
+ println("libtmux capture ready")
+ } catch (error: Throwable) {
+ failure = error
+ throw error
+ } finally {
+ withContext(NonCancellable) {
+ try {
+ if (Files.exists(socket)) server.killServer()
+ Files.deleteIfExists(socket)
+ Files.delete(directory)
+ } catch (cleanup: Throwable) {
+ val original = failure
+ if (original == null) throw cleanup
+ original.addSuppressed(cleanup)
+ }
+ }
+ }
+ }
+ } finally {
+ // Opening a client can fail before the cleanup block is entered.
+ if (!Files.exists(socket)) Files.deleteIfExists(directory)
+ }
+}
+```
+
+
+
+## Wait for output or completion
+
+Capture and input calls suspend. `withTimeout` bounds the polling loop, and `delay` yields between captures. `withServer` closes the client; the `finally` block also stops the tmux server this program created. Cleanup errors stay visible, and a server that cannot be stopped keeps its socket.
+
+Capture reads screen state and scrollback, so output that has scrolled away
+may be absent. The program prints `libtmux capture ready` when its check passes
+and exits unsuccessfully if an operation fails.
+
+## Setup and run
+
+Use an empty directory and save the files using the displayed names. You need
+JDK 25. The pinned repository supplies Gradle; the project files select Kotlin 2.4.10 and include the library build.
+
+Save `settings.gradle.kts` beside `Capture.kt`.
+
+```kotlin title="settings.gradle.kts"
+rootProject.name = "capture"
+includeBuild("libtmux-source")
+```
+
+Save `build.gradle.kts` beside `Capture.kt`.
+
+```kotlin title="build.gradle.kts"
+plugins {
+ application
+ kotlin("jvm") version "2.4.10"
+}
+
+repositories { mavenCentral() }
+dependencies {
+ implementation("io.github.libtmux:libtmux-kotlin:0.0.1-alpha.17-SNAPSHOT")
+}
+kotlin {
+ jvmToolchain(25)
+ sourceSets.main { kotlin.srcDir("."); kotlin.include("Capture.kt") }
+}
+application { mainClass.set("CaptureKt") }
+```
+
+Save `gradle.properties` beside `Capture.kt`.
+
+```properties title="gradle.properties"
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx2g
+org.gradle.workers.max=2
+```
+
+The commands pin the library revision used to run this program.
+
+```console
+$ git clone https://github.com/libtmux/libtmux-java libtmux-source &&
+ git -C libtmux-source checkout 85ebf6955e56703c5be74e2afd34a18309044741 &&
+ ./libtmux-source/gradlew --project-dir . run --console=plain --max-workers=2
+```
+
+
+
+## Where this comes from
+
+The displayed files were compiled or loaded with their native tools and run
+on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
diff --git a/site/src/content/docs/ports/lua/examples/capture-pane-output.md b/site/src/content/docs/ports/lua/examples/capture-pane-output.md
new file mode 100644
index 00000000..12ac7be5
--- /dev/null
+++ b/site/src/content/docs/ports/lua/examples/capture-pane-output.md
@@ -0,0 +1,131 @@
+---
+port: lua
+route: examples/capture-pane-output
+title: Capture pane output
+description: Run a complete Lua program that captures output on an isolated tmux server.
+sidebar:
+ label: Capture pane output
+ group: Examples
+ order: 3
+tableOfContents: true
+---
+
+This complete Lua program starts a private tmux server, sends a command,
+and captures the line it prints. It includes imports, setup, and cleanup.
+You need tmux and a Unix environment; no existing session is required.
+
+Save both files shown below. Run `sh run.sh` after the [setup](#setup-and-run);
+the launcher creates the server and passes its socket to the Lua program.
+
+## Read what's on screen
+
+The leading newline puts the output on a fresh row. Matching the whole line
+avoids mistaking the echoed command for its output.
+
+```lua title="capture.lua"
+local adapter = require("libtmux.runtime.luv")
+
+local function must(value, err)
+ if err ~= nil then error(tostring(err), 0) end
+ return value
+end
+
+local function quote(text)
+ return "'" .. text:gsub("'", "'\\''") .. "'"
+end
+
+local socket = assert(arg[1], "pass the private socket path")
+local binary = assert(arg[2], "pass the absolute tmux executable path")
+
+must(adapter.run(function(runtime)
+ local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
+ local created = must(server:new_session({
+ name = "capture", argv = { "/bin/sh" },
+ }):await())
+ local pane = created.pane
+ local command = "printf '\\nlibtmux capture ready\\n'; "
+ .. quote(binary) .. " -S " .. quote(socket) .. " wait-for -S capture-ready"
+ must(pane:send_text(command):await())
+ must(pane:send_keys({ "Enter" }):await())
+ must(server:command({ "wait-for", "capture-ready" }, { timeout = 5000 }):await())
+
+ local capture = must(pane:capture({ history_lines = 20 }):await())
+ local found = false
+ for line in must(capture:text()):gmatch("[^\r\n]+") do
+ if line == "libtmux capture ready" then found = true end
+ end
+ assert(found, "The completed command did not produce the expected line")
+ print("libtmux capture ready")
+ must(server:close():await())
+ return true
+end))
+```
+
+
+
+## Wait for output or completion
+
+The pane prints its line, then signals a tmux `wait-for` channel on the same private socket. The awaited command has a five-second deadline. Capture runs after that signal and checks the complete line. The launcher stops its server on success or failure; if stopping fails, it reports the error and keeps the socket directory.
+
+Capture reads screen state and scrollback, so output that has scrolled away
+may be absent. The program prints `libtmux capture ready` when its check passes
+and exits unsuccessfully if an operation fails.
+
+## Setup and run
+
+Use an empty directory and save the files using the displayed names. You need
+Lua 5.5, LuaRocks, a C compiler, and CMake. The commands install libtmux and its luv runtime adapter into a local rocks tree.
+
+Save this launcher beside the Lua program.
+
+```sh title="run.sh"
+#!/bin/sh
+set -eu
+
+binary=$(command -v tmux)
+case "$binary" in
+ /*) ;;
+ *) printf '%s\n' 'tmux must resolve to an absolute path' >&2; exit 1 ;;
+esac
+mkdir -p /tmp/libtmux-lua-dev
+directory=$(mktemp -d /tmp/libtmux-lua-dev/capture.XXXXXX)
+socket="$directory/tmux.sock"
+
+cleanup() {
+ status=$?
+ trap - 0 HUP INT TERM
+ if [ -S "$socket" ]; then
+ if ! "$binary" -S "$socket" kill-server; then
+ printf 'Could not stop server; retained %s\n' "$directory" >&2
+ exit 1
+ fi
+ fi
+ rm -rf "$directory" || exit 1
+ exit "$status"
+}
+trap cleanup 0
+trap 'exit 1' HUP INT TERM
+
+export ENV=/dev/null
+"$binary" -S "$socket" -f /dev/null new-session -d -s bootstrap /bin/cat
+lua capture.lua "$socket" "$binary"
+```
+
+The commands pin the library revision used to run this program.
+
+```console
+$ git clone https://github.com/libtmux/libtmux-lua libtmux-source &&
+ git -C libtmux-source checkout 5baa3f9b830ebdbc76fb50b5b3d7a5ad3f76d443 &&
+ luarocks --tree ./rocks install luv 1.52.1-0 &&
+ (cd libtmux-source &&
+ luarocks --tree ../rocks make rockspecs/libtmux-scm-1.rockspec) &&
+ eval "$(luarocks --tree ./rocks path)" &&
+ sh run.sh
+```
+
+
+
+## Where this comes from
+
+The displayed files were compiled or loaded with their native tools and run
+on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
diff --git a/site/src/content/docs/ports/ruby/examples/capture-pane-output.md b/site/src/content/docs/ports/ruby/examples/capture-pane-output.md
new file mode 100644
index 00000000..c4031b1c
--- /dev/null
+++ b/site/src/content/docs/ports/ruby/examples/capture-pane-output.md
@@ -0,0 +1,86 @@
+---
+port: ruby
+route: examples/capture-pane-output
+title: Capture pane output
+description: Run a complete Ruby program that captures output on an isolated tmux server.
+sidebar:
+ label: Capture pane output
+ group: Examples
+ order: 3
+tableOfContents: true
+---
+
+This complete Ruby program starts a private tmux server, sends a command,
+and captures the line it prints. It includes imports, setup, and cleanup.
+You need tmux and a Unix environment; no existing session is required.
+
+## Read what's on screen
+
+The leading newline puts the output on a fresh row. Matching the whole line
+avoids mistaking the echoed command for its output.
+
+```ruby title="capture.rb"
+require "libtmux"
+
+LibTmux::Server.start do |server|
+ created = server.new_session(
+ name: "capture", command: ["/bin/sh"],
+ environment: {"ENV" => "/dev/null"}, receipt: true
+ )
+ pane = created.pane
+ pane.send_text("printf '\\nlibtmux capture ready\\n'")
+ pane.send_keys("Enter")
+
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
+ loop do
+ if pane.capture.stdout.lines(chomp: true).include?("libtmux capture ready")
+ puts "libtmux capture ready"
+ break
+ end
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
+ raise "Output did not arrive within five seconds"
+ end
+ sleep 0.025
+ end
+end
+```
+
+
+
+## Wait for output or completion
+
+`LibTmux::Server.start` owns the private server. Leaving its block stops the server, including when capture or the deadline raises an error.
+
+Capture reads screen state and scrollback, so output that has scrolled away
+may be absent. The program prints `libtmux capture ready` when its check passes
+and exits unsuccessfully if an operation fails.
+
+## Setup and run
+
+Use an empty directory and save the files using the displayed names. You need
+Ruby 4.0.7 and Bundler. Bundler installs the library and its runtime dependencies.
+
+Save this dependency file beside the program.
+
+```ruby title="Gemfile"
+source "https://rubygems.org"
+
+gem "libtmux", path: "libtmux-source/gems/libtmux"
+```
+
+The commands pin the library revision used to run this program.
+
+```console
+$ git clone https://github.com/libtmux/libtmux-ruby libtmux-source &&
+ git -C libtmux-source checkout 2599d45369515aaf2fd5793bfe71cdc20de641b6 &&
+ bundle config set --local path vendor/bundle &&
+ bundle install &&
+ bundle exec ruby capture.rb
+```
+
+
+
+## Where this comes from
+
+The displayed files were compiled or loaded with their native tools and run
+on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
diff --git a/site/src/content/docs/ports/scala/examples.md b/site/src/content/docs/ports/scala/examples.md
index 8a323983..efe8980c 100644
--- a/site/src/content/docs/ports/scala/examples.md
+++ b/site/src/content/docs/ports/scala/examples.md
@@ -7,6 +7,10 @@ sidebar:
group: Examples
---
+Start with [Capture pane output](./capture-pane-output/) for a standalone program with imports, Gradle files, and private-server cleanup.
+
+## Create a workspace
+
Create a session with a split window, send text, wait for printed output, and query captured panes with the Scala facade. The examples module supplies `ExampleRuntime`. Its executable test passes an owned socket and checks that the original sessions and clients remain after this program exits. Run `./gradlew :examples:test` from the Java repository.
```scala file="examples/src/main/scala/io/github/libtmux/scaladsl/examples/BlockingWorkspace.scala"
diff --git a/site/src/content/docs/ports/scala/examples/capture-pane-output.md b/site/src/content/docs/ports/scala/examples/capture-pane-output.md
new file mode 100644
index 00000000..e8fabf66
--- /dev/null
+++ b/site/src/content/docs/ports/scala/examples/capture-pane-output.md
@@ -0,0 +1,147 @@
+---
+port: scala
+route: examples/capture-pane-output
+title: Capture pane output
+description: Run a complete Scala program that captures output on an isolated tmux server.
+sidebar:
+ label: Capture pane output
+ group: Examples
+ order: 3
+tableOfContents: true
+---
+
+This complete Scala program starts a private tmux server, sends a command,
+and captures the line it prints. It includes imports, setup, and cleanup.
+You need tmux and a Unix environment; no existing session is required.
+
+## Read what's on screen
+
+The leading newline puts the output on a fresh row. Matching the whole line
+avoids mistaking the echoed command for its output.
+
+```scala title="Capture.scala"
+import io.github.libtmux.{ServerConfig, ServerEndpoint, SessionSpec}
+import io.github.libtmux.scaladsl.*
+import java.nio.file.{Files, Path}
+import java.time.Duration
+import scala.util.Using
+
+object Capture {
+ def main(args: Array[String]): Unit = {
+ val root = Files.createDirectories(Path.of("/tmp/libtmux-java-dev"))
+ val directory = Files.createTempDirectory(root, "capture-")
+ val socket = directory.resolve("tmux.sock")
+ val config = ServerConfig.builder()
+ .endpoint(ServerEndpoint.socketPath(socket))
+ .configFile(Path.of("/dev/null"))
+ .defaultTimeout(Duration.ofSeconds(1))
+ .build()
+ var failure: Option[Throwable] = None
+ try {
+ Using.resource(Server.open(config)) { server =>
+ try {
+ val session = server.newSession(SessionSpec.builder()
+ .named("capture").running("/bin/sh").env("ENV", "/dev/null").build())
+ val pane = session.windows.head.panes.head
+ pane.sendLine("printf '\\nlibtmux capture ready\\n'")
+ val deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos
+ var captured = false
+ while (!captured && System.nanoTime() < deadline) {
+ captured = pane.capture().contains("libtmux capture ready")
+ if (!captured) Thread.sleep(25)
+ }
+ if (!captured)
+ throw new IllegalStateException("Output did not arrive within five seconds")
+ println("libtmux capture ready")
+ } catch {
+ case error: Throwable =>
+ failure = Some(error)
+ throw error
+ } finally {
+ try {
+ if (Files.exists(socket)) server.killServer()
+ Files.deleteIfExists(socket)
+ Files.delete(directory)
+ } catch {
+ case cleanup: Throwable => failure match {
+ case Some(original) => original.addSuppressed(cleanup)
+ case None => throw cleanup
+ }
+ }
+ }
+ }
+ } finally {
+ // Opening a client can fail before the cleanup block is entered.
+ if (!Files.exists(socket)) Files.deleteIfExists(directory)
+ }
+ }
+}
+```
+
+
+
+## Wait for output or completion
+
+The Scala facade uses blocking calls. The loop checks complete captured lines against a monotonic deadline. `Using.resource` closes the client; the `finally` block also stops the tmux server this program created. Cleanup errors stay visible, and a server that cannot be stopped keeps its socket.
+
+Capture reads screen state and scrollback, so output that has scrolled away
+may be absent. The program prints `libtmux capture ready` when its check passes
+and exits unsuccessfully if an operation fails.
+
+## Setup and run
+
+Use an empty directory and save the files using the displayed names. You need
+JDK 25. The pinned repository supplies Gradle; the project files select Scala 3.9.0 and include the library build.
+
+Save `settings.gradle.kts` beside `Capture.scala`.
+
+```kotlin title="settings.gradle.kts"
+rootProject.name = "capture"
+includeBuild("libtmux-source") {
+ dependencySubstitution {
+ substitute(module("io.github.libtmux:libtmux-scala_3"))
+ .using(project(":libtmux-scala"))
+ }
+}
+```
+
+Save `build.gradle.kts` beside `Capture.scala`.
+
+```kotlin title="build.gradle.kts"
+plugins {
+ application
+ scala
+}
+
+repositories { mavenCentral() }
+dependencies {
+ implementation("org.scala-lang:scala3-library_3:3.9.0")
+ implementation("io.github.libtmux:libtmux-scala_3:0.0.1-alpha.17-SNAPSHOT")
+}
+java { toolchain { languageVersion.set(JavaLanguageVersion.of(25)) } }
+sourceSets.main { scala.srcDir("."); scala.include("Capture.scala") }
+application { mainClass.set("Capture") }
+```
+
+Save `gradle.properties` beside `Capture.scala`.
+
+```properties title="gradle.properties"
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8
+kotlin.daemon.jvmargs=-Xmx2g
+org.gradle.workers.max=2
+```
+
+The commands pin the library revision used to run this program.
+
+```console
+$ git clone https://github.com/libtmux/libtmux-java libtmux-source &&
+ git -C libtmux-source checkout 85ebf6955e56703c5be74e2afd34a18309044741 &&
+ ./libtmux-source/gradlew --project-dir . run --console=plain --max-workers=2
+```
+
+
+
+## Where this comes from
+
+The displayed files were compiled or loaded with their native tools and run
+on Linux with tmux 3.2a and 3.7c. The rendering checks preserve those file bytes.
diff --git a/site/src/data/mentions.json b/site/src/data/mentions.json
index 68c1b55a..09710aea 100644
--- a/site/src/data/mentions.json
+++ b/site/src/data/mentions.json
@@ -5866,6 +5866,13 @@
"title": "libtmux for Ruby",
"section": "guides"
},
+ {
+ "port": "ruby",
+ "symbol": "LibTmux::Server.start",
+ "page": "/ruby/latest/examples/capture-pane-output/",
+ "title": "Capture pane output",
+ "section": "examples"
+ },
{
"port": "ruby",
"symbol": "LibTmux::Session#new_window",
diff --git a/site/test/complete-examples.test.ts b/site/test/complete-examples.test.ts
index 8ea70dcf..26db49eb 100644
--- a/site/test/complete-examples.test.ts
+++ b/site/test/complete-examples.test.ts
@@ -87,7 +87,7 @@ describe('verified complete programs', () => {
expect(root).toContain(`/${example.port}/latest/${receipt.page}/`)
return { id: example.page, data: { port: example.port, route: receipt.page } }
})]
- expect(new Set(docs.map((doc) => docsRoutePath(doc))).size).toBe(9)
+ expect(new Set(docs.map((doc) => docsRoutePath(doc))).size).toBe(receipt.examples.length + 1)
for (const example of receipt.examples) {
const available = docs.filter((doc) => docsEntryAvailable(doc, example.port))
expect(available.map((doc) => docsRoutePath(doc, example.port))).toEqual([receipt.page])
diff --git a/site/test/fixtures/capture-examples.json b/site/test/fixtures/capture-examples.json
index 475e5e71..dd86de96 100644
--- a/site/test/fixtures/capture-examples.json
+++ b/site/test/fixtures/capture-examples.json
@@ -191,7 +191,8 @@
"files": [
{
"name": "Capture.swift",
- "sha256": "4f4258a1eb646d70712559f5a4d975d1cc36fd1805a77cbb8ddc63bc812db6da"
+ "sha256": "4f4258a1eb646d70712559f5a4d975d1cc36fd1805a77cbb8ddc63bc812db6da",
+ "path": "Sources/Capture/Capture.swift"
},
{
"name": "Package.swift",
@@ -209,6 +210,368 @@
"shellRecipe": [
"git clone https://github.com/libtmux/libtmux-swift libtmux-source &&\n git -C libtmux-source checkout 254f8b2be7eb60cacc3ffcb3ea8e456784f582df &&\n swift run --jobs 2 Capture"
]
+ },
+ {
+ "port": "kotlin",
+ "language": "kotlin",
+ "sourceRevision": "85ebf6955e56703c5be74e2afd34a18309044741",
+ "sourceRepository": "libtmux/libtmux-java",
+ "files": [
+ {
+ "name": "Capture.kt",
+ "sha256": "afd4b19f973c95cb434a859e5a12aeb60ffbd97a15951a1c63f37958e1d1a85a"
+ },
+ {
+ "name": "build.gradle.kts",
+ "sha256": "a705fe6e99a1931cb90267adf81650a44a93b6580c5277f0657d468287be5068"
+ },
+ {
+ "name": "settings.gradle.kts",
+ "sha256": "7c01a4d1d8620741df4852dad3a03e0eac7a084a2bbb20e53016064cab1f958b"
+ },
+ {
+ "name": "gradle.properties",
+ "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1"
+ }
+ ],
+ "toolchain": [
+ "JDK 25.0.3",
+ "Kotlin 2.4.10"
+ ],
+ "tmux": [
+ "3.2a",
+ "3.7c"
+ ],
+ "page": "ports/kotlin/examples/capture-pane-output",
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout 85ebf6955e56703c5be74e2afd34a18309044741 &&\n ./libtmux-source/gradlew --project-dir . run --console=plain --max-workers=2"
+ ],
+ "verification": {
+ "platform": "Linux",
+ "checks": [
+ {
+ "name": "positive-3.2a",
+ "tmux": "3.2a",
+ "exit": 0,
+ "seconds": 2.282,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "positive-3.7c",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 2.183,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-import",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 2.197,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 1.905,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 6.772,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "restored-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 1.825,
+ "remainingSocketDirectories": 0
+ }
+ ]
+ }
+ },
+ {
+ "port": "scala",
+ "language": "scala",
+ "sourceRevision": "85ebf6955e56703c5be74e2afd34a18309044741",
+ "sourceRepository": "libtmux/libtmux-java",
+ "files": [
+ {
+ "name": "Capture.scala",
+ "sha256": "1514add5014928157a9110c68bb3032476d921b6b9c5fcc8d74c9eed76f32be5"
+ },
+ {
+ "name": "build.gradle.kts",
+ "sha256": "a770d0b99553598b21e82030b6c7d42215769abca70789c2aae836956a70b3c5"
+ },
+ {
+ "name": "settings.gradle.kts",
+ "sha256": "b714b16f8397fc43c5780c8a1c330ed85dcd80c3c5c565023f7504fb8fa9b06b"
+ },
+ {
+ "name": "gradle.properties",
+ "sha256": "aa2f75584718e340a2dab7af0d04d010cd8d0b83fc6b16f96a141cd3244821c1"
+ }
+ ],
+ "toolchain": [
+ "JDK 25.0.3",
+ "Scala 3.9.0"
+ ],
+ "tmux": [
+ "3.2a",
+ "3.7c"
+ ],
+ "page": "ports/scala/examples/capture-pane-output",
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-java libtmux-source &&\n git -C libtmux-source checkout 85ebf6955e56703c5be74e2afd34a18309044741 &&\n ./libtmux-source/gradlew --project-dir . run --console=plain --max-workers=2"
+ ],
+ "verification": {
+ "platform": "Linux",
+ "checks": [
+ {
+ "name": "positive-3.2a",
+ "tmux": "3.2a",
+ "exit": 0,
+ "seconds": 1.809,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "positive-3.7c",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 1.596,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-import",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 3.527,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 4.32,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 6.524,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "restored-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 4.331,
+ "remainingSocketDirectories": 0
+ }
+ ]
+ }
+ },
+ {
+ "port": "fsharp",
+ "language": "fsharp",
+ "sourceRevision": "661287848a6cfb407f37114b25e8249a29f99e3f",
+ "sourceRepository": "libtmux/libtmux-dotnet",
+ "files": [
+ {
+ "name": "Program.fs",
+ "sha256": "10e4320d65f9214988a3c2203576407398646a2e6ca6d829849e15a911df10c0"
+ },
+ {
+ "name": "Capture.fsproj",
+ "sha256": "314bfb2bef111e8885728a0065943a59594721011da4ac6ef0275aebd8f14bcd"
+ }
+ ],
+ "toolchain": [
+ ".NET SDK 10.0.302"
+ ],
+ "tmux": [
+ "3.2a",
+ "3.7c"
+ ],
+ "page": "ports/fsharp/examples/capture-pane-output",
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-dotnet libtmux-source &&\n git -C libtmux-source checkout 661287848a6cfb407f37114b25e8249a29f99e3f &&\n dotnet build Capture.fsproj --maxcpucount:1 \\\n -p:DisableImplicitLibraryPacksFolder=true \\\n -p:RestorePackagesPath=\"$PWD/.packages\" &&\n dotnet run --project Capture.fsproj --no-build"
+ ],
+ "verification": {
+ "platform": "Linux",
+ "checks": [
+ {
+ "name": "positive-3.2a",
+ "tmux": "3.2a",
+ "exit": 0,
+ "seconds": 1.061,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "positive-3.7c",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 0.923,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-import",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 2.171,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 2.288,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 5.919,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "restored-build",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 2.903,
+ "remainingSocketDirectories": 0
+ }
+ ]
+ }
+ },
+ {
+ "port": "ruby",
+ "language": "ruby",
+ "sourceRevision": "2599d45369515aaf2fd5793bfe71cdc20de641b6",
+ "sourceRepository": "libtmux/libtmux-ruby",
+ "files": [
+ {
+ "name": "capture.rb",
+ "sha256": "15ab560716a71ef94f0b3161e09599eb090c42f533e235cac035234aea58748b"
+ },
+ {
+ "name": "Gemfile",
+ "sha256": "c0139fb5c549a7f4ccf72cf4ebf921de0497dbe9ea9a2e7c5756504ed5bf2f7c"
+ }
+ ],
+ "toolchain": [
+ "Ruby 4.0.7",
+ "Bundler"
+ ],
+ "tmux": [
+ "3.2a",
+ "3.7c"
+ ],
+ "page": "ports/ruby/examples/capture-pane-output",
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-ruby libtmux-source &&\n git -C libtmux-source checkout 2599d45369515aaf2fd5793bfe71cdc20de641b6 &&\n bundle config set --local path vendor/bundle &&\n bundle install &&\n bundle exec ruby capture.rb"
+ ],
+ "verification": {
+ "platform": "Linux",
+ "checks": [
+ {
+ "name": "positive-3.2a",
+ "tmux": "3.2a",
+ "exit": 0,
+ "seconds": 0.529,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "positive-3.7c",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 0.411,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-import",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 0.455,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 5.416,
+ "remainingSocketDirectories": 0
+ }
+ ]
+ }
+ },
+ {
+ "port": "lua",
+ "language": "lua",
+ "sourceRevision": "5baa3f9b830ebdbc76fb50b5b3d7a5ad3f76d443",
+ "sourceRepository": "libtmux/libtmux-lua",
+ "files": [
+ {
+ "name": "capture.lua",
+ "sha256": "a557a96fa3513a318cf6c17ac286d5629b1ffc05c53a466658a22a41891da89f"
+ },
+ {
+ "name": "run.sh",
+ "sha256": "33f7e048ee0682965314f47ff29ae8f57e5c67ab5466f49338b0aae7491c2b5a"
+ }
+ ],
+ "toolchain": [
+ "Lua 5.5.1",
+ "luv 1.52.1-0"
+ ],
+ "tmux": [
+ "3.2a",
+ "3.7c"
+ ],
+ "page": "ports/lua/examples/capture-pane-output",
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-lua libtmux-source &&\n git -C libtmux-source checkout 5baa3f9b830ebdbc76fb50b5b3d7a5ad3f76d443 &&\n luarocks --tree ./rocks install luv 1.52.1-0 &&\n (cd libtmux-source &&\n luarocks --tree ../rocks make rockspecs/libtmux-scm-1.rockspec) &&\n eval \"$(luarocks --tree ./rocks path)\" &&\n sh run.sh"
+ ],
+ "verification": {
+ "platform": "Linux",
+ "checks": [
+ {
+ "name": "positive-3.2a",
+ "tmux": "3.2a",
+ "exit": 0,
+ "seconds": 0.316,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "positive-3.7c",
+ "tmux": "3.7c",
+ "exit": 0,
+ "seconds": 0.243,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-import",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 0.23,
+ "remainingSocketDirectories": 0
+ },
+ {
+ "name": "negative-runtime",
+ "tmux": "3.7c",
+ "exit": 1,
+ "seconds": 0.272,
+ "remainingSocketDirectories": 0
+ }
+ ]
+ }
}
],
"rootExample": {