diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 94b21f7..98517b9 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -372,6 +372,25 @@ target frameworks, generates an SBOM and a provenance attestation, and pushes
to NuGet through trusted publishing. Renaming that workflow file breaks the
trusted-publishing policy registered on nuget.org, so change the policy first.
+### The psmux preview gates
+
+Publishing the preview needs the accepted Windows x64 executable, which is a
+maintainer validation build rather than a published psmux release asset.
+[`docs/psmux.md`](../docs/psmux.md) states the trust boundary it has to meet.
+
+`release.yml` reads the repository variables `PSMUX_ARTIFACT_URL`,
+`PSMUX_SOURCE_PROVENANCE_URL`, `PSMUX_LICENSE_URL`, `PSMUX_WSL_DISTRIBUTION`
+and `PSMUX_WSL_DOTNET_PATH`, and needs a self-hosted `Windows`, `X64`, `psmux`
+runner for the native and WSL gates. `PSMUX_WSL_DOTNET_PATH` is the absolute
+Linux `dotnet` path for that checkout, which the runner reports:
+
+```console
+$ mise exec -- which dotnet
+```
+
+Those inputs make the gates runnable; they do not by themselves complete the
+artifact or the runtime evidence.
+
### Recorded evidence is a release artifact
A capability row is `pending` until a matrix run records evidence for it, and
diff --git a/.github/workflows/dotnet-tmux.yml b/.github/workflows/dotnet-tmux.yml
index ff83d7c..efa3c50 100644
--- a/.github/workflows/dotnet-tmux.yml
+++ b/.github/workflows/dotnet-tmux.yml
@@ -4,6 +4,7 @@
name: dotnet-tmux
on:
+ workflow_call:
push:
branches: [master]
pull_request:
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 0540a56..f7e0369 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -4,6 +4,7 @@
name: dotnet
on:
+ workflow_call:
push:
branches: [master]
pull_request:
@@ -109,8 +110,8 @@ jobs:
done
- name: Examples
- # An example that stopped working should fail the build, not the
- # reader who copied it.
+ # An ordinary-tmux example that stopped working should fail here,
+ # before a reader copies it.
run: >
dotnet run
--project examples/LibTmux.Examples/LibTmux.Examples.csproj
@@ -119,10 +120,8 @@ jobs:
--no-build
- name: Example tests
- # The same examples again, one test each, so a broken example is named
- # in a report rather than being an exit code. This reaches them through
- # the examples project and never through a project reference of its
- # own, which is what makes it a reading of the surface a caller has.
+ # The ordinary-tmux examples run again, one test each, through the
+ # same compiled surface a caller reads.
run: >
dotnet test
--project tests/LibTmux.ExampleTests/LibTmux.ExampleTests.csproj
@@ -153,8 +152,10 @@ jobs:
# code that runs, and this is what says so out loud.
run: |
uv run python eng/parity/verify_public_api.py
+ uv run python eng/parity/render_public_api.py --check
uv run python eng/parity/verify_capabilities.py
uv run python eng/parity/verify_workflows.py
+ uv run python eng/docs/render_api_reference.py --check
uv run python eng/docs/sync_snippets.py --check
uv run eng/mcp/dump_tools.py --check
@@ -181,13 +182,8 @@ jobs:
path: artifacts/packages
if-no-files-found: warn
- # The compatibility claim names macOS, and until now only Linux was ever run.
- # The first run of this lane failed 15 of 854 integration tests, which is the
- # answer it was added to get: the macOS support was asserted, not proven.
- #
- # It is advisory until those are fixed. Requiring it would block every commit
- # on a platform difference nobody has diagnosed yet, and deleting it would go
- # back to not knowing. Reporting it is the honest middle.
+ # macOS exercises the advertised platform but stays advisory; failures remain
+ # visible without making the primary Linux gate depend on hosted macOS.
macos:
name: macos arm64 (advisory)
runs-on: macos-latest
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0afe122..f84d0e5 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,7 +1,5 @@
-# Publishing. A version on nuget.org can never be deleted, only unlisted, so
-# everything that can be proven about a package is proven here before the push
-# rather than after it: the gate's build and tests, the package inspection, and
-# a consumer that restores the built package from a folder feed and runs it.
+# Publishing waits for same-commit gates and native/WSL psmux proof before
+# nuget.org grants a token for its immutable feed.
#
# The name of this file is part of the trusted publishing policy on nuget.org.
# Renaming it means editing that policy, and until then no publish is possible.
@@ -25,97 +23,203 @@ env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true'
jobs:
- publish:
- name: publish to nuget.org
+ validate:
+ name: validate release ref
runs-on: ubuntu-latest
-
- # The policy on nuget.org names this environment, so a workflow that ran
- # outside it cannot exchange a token. It is also where a required reviewer
- # and a tag restriction belong.
- environment: nuget
-
- permissions:
- contents: read
- # Lets the job ask GitHub for the short-lived token nuget.org trades for
- # a one-hour API key. Nothing else here needs a credential.
- id-token: write
- attestations: write
-
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- # SourceLink points a debugger at the commit that produced the
- # assembly, which a shallow checkout does not have.
fetch-depth: 0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: global.json
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
-
- - name: Install tmux
- # The consumer proof drives a real server rather than a stub.
- run: |
- sudo apt-get update
- sudo apt-get install --yes tmux
-
- name: Check the tag matches the version
- # A tag that disagrees with the version publishes something permanently
- # mislabelled, and the mislabelling is what nobody can take back.
- if: github.event_name == 'push'
env:
+ REF_TYPE: ${{ github.ref_type }}
TAG: ${{ github.ref_name }}
run: |
+ set -euo pipefail
+ if [ "${REF_TYPE}" != tag ] || [[ "${TAG}" != v* ]]; then
+ echo "release must run at a v* tag, not ${REF_TYPE} ${TAG}" >&2
+ exit 1
+ fi
version="$(dotnet msbuild src/LibTmux/LibTmux.csproj \
-getProperty:Version -verbosity:quiet | tr -d '[:space:]')"
if [ "${TAG}" != "v${version}" ]; then
echo "tag ${TAG} does not match version ${version}" >&2
exit 1
fi
- echo "publishing ${version} as ${TAG}"
+ echo "validated ${TAG} at ${GITHUB_SHA}"
- - name: Restore
- run: dotnet restore LibTmux.slnx --locked-mode
+ dotnet:
+ name: full dotnet gate
+ needs: validate
+ uses: ./.github/workflows/dotnet.yml
- - name: Build
- run: dotnet build LibTmux.slnx --configuration Release --no-restore --warnaserror
+ compatibility:
+ name: supported tmux matrix
+ needs: validate
+ uses: ./.github/workflows/dotnet-tmux.yml
- - name: Unit tests
- run: >
- dotnet test
- --project tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj
- --configuration Release
- --framework net10.0
- --no-build
- --minimum-expected-tests 1
+ psmux-metadata:
+ name: published psmux artifact
+ needs: validate
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Verify the published artifact and provenance links
+ env:
+ ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }}
+ LICENSE_URL: ${{ vars.PSMUX_LICENSE_URL }}
+ SOURCE_URL: ${{ vars.PSMUX_SOURCE_PROVENANCE_URL }}
+ run: |
+ set -euo pipefail
+ for variable in ARTIFACT_URL LICENSE_URL SOURCE_URL; do
+ value="${!variable:-}"
+ if [[ "${value}" != https://* ]]; then
+ echo "${variable} must name a published HTTPS resource" >&2
+ exit 1
+ fi
+ done
+ if [[ "${SOURCE_URL}" != *aa26cd39edcfab03e718f94ea21bb47e8c5b85e8* ]]; then
+ echo "SOURCE_URL must attest the exact audited psmux commit" >&2
+ exit 1
+ fi
+ curl --fail --location --proto '=https' --tlsv1.2 \
+ --connect-timeout 15 --max-time 120 \
+ "${ARTIFACT_URL}" --output "${RUNNER_TEMP}/psmux.exe"
+ echo "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e ${RUNNER_TEMP}/psmux.exe" \
+ | sha256sum --check --strict
+ curl --fail --location --proto '=https' --tlsv1.2 \
+ --connect-timeout 15 --max-time 120 \
+ "${SOURCE_URL}" --output "${RUNNER_TEMP}/psmux-source-provenance"
+ curl --fail --location --proto '=https' --tlsv1.2 \
+ --connect-timeout 15 --max-time 120 \
+ "${LICENSE_URL}" --output "${RUNNER_TEMP}/psmux-license"
+ test -s "${RUNNER_TEMP}/psmux-source-provenance"
+ test -s "${RUNNER_TEMP}/psmux-license"
- - name: Pack
- run: dotnet pack LibTmux.slnx --configuration Release --no-build --output artifacts/packages
+ psmux:
+ name: psmux native Windows and WSL
+ needs: [validate, psmux-metadata]
+ runs-on: [self-hosted, Windows, X64, psmux]
+ timeout-minutes: 45
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
- - name: Inspect the packages
- # Metadata, assemblies, dependencies and symbols, per package. What
- # this catches is only fixable before the version exists.
- run: uv run python eng/parity/inspect_packages.py
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: global.json
- - name: Prove the package installs and runs
- # Reaching the library through the built package rather than a project
- # reference is the last thing that can fail while it is still private.
+ - name: Download the audited psmux artifact
+ shell: pwsh
+ env:
+ ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ Invoke-WebRequest `
+ -Uri $env:ARTIFACT_URL `
+ -OutFile (Join-Path $env:RUNNER_TEMP 'psmux.exe')
+
+ - name: Build the native and packed consumers
+ shell: pwsh
run: |
+ $ErrorActionPreference = 'Stop'
+ $env:NUGET_PACKAGES = Join-Path `
+ $env:RUNNER_TEMP `
+ "libtmux-psmux-nuget-$([Guid]::NewGuid().ToString('N'))"
+ dotnet restore LibTmux.slnx --locked-mode
+ dotnet build LibTmux.slnx `
+ --configuration Release `
+ --no-restore `
+ --warnaserror
+ dotnet pack LibTmux.slnx `
+ --configuration Release `
+ --no-build `
+ --output artifacts/packages
dotnet restore tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj
- for framework in net8.0 net10.0; do
- dotnet run \
- --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \
- --configuration Release \
- --framework "${framework}" \
- --no-restore
- done
+ foreach ($framework in @('net8.0', 'net10.0')) {
+ dotnet build tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj `
+ --configuration Release `
+ --framework $framework `
+ --no-restore `
+ --warnaserror
+ }
+
+ - name: Run the native and WSL psmux harness
+ shell: pwsh
+ env:
+ WSL_DISTRIBUTION: ${{ vars.PSMUX_WSL_DISTRIBUTION }}
+ WSL_DOTNET_PATH: ${{ vars.PSMUX_WSL_DOTNET_PATH }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ if ([string]::IsNullOrWhiteSpace($env:WSL_DISTRIBUTION)) {
+ throw 'PSMUX_WSL_DISTRIBUTION must name the release runner distribution.'
+ }
+ if ([string]::IsNullOrWhiteSpace($env:WSL_DOTNET_PATH)) {
+ throw 'PSMUX_WSL_DOTNET_PATH must name the WSL dotnet executable.'
+ }
+ $dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source
+ foreach ($framework in @('net8.0', 'net10.0')) {
+ $nonce = [Guid]::NewGuid().ToString('N')
+ & .\eng\psmux\Invoke-PsmuxSmoke.ps1 `
+ -PsmuxPath (Join-Path $env:RUNNER_TEMP 'psmux.exe') `
+ -ExpectedSha256 '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e' `
+ -DataDirectory (Join-Path $env:RUNNER_TEMP "libtmux-psmux-$nonce") `
+ -NamespaceName "libtmux_smoke_$($nonce.Substring(0, 16))" `
+ -DotnetPath $dotnet `
+ -TestAssembly (Join-Path $env:GITHUB_WORKSPACE "tests\LibTmux.UnitTests\bin\Release\$framework\LibTmux.UnitTests.dll") `
+ -ExampleAssembly (Join-Path $env:GITHUB_WORKSPACE "examples\LibTmux.Examples\bin\Release\$framework\LibTmux.Examples.dll") `
+ -PackageConsumerAssembly (Join-Path $env:GITHUB_WORKSPACE "tests\LibTmux.PackageConsumer\bin\Release\$framework\LibTmux.PackageConsumer.dll") `
+ -TargetFramework $framework `
+ -RunWslSmoke `
+ -WslDistribution $env:WSL_DISTRIBUTION `
+ -WslRepository $env:GITHUB_WORKSPACE `
+ -WslDotnetPath $env:WSL_DOTNET_PATH
+ }
+
+ publish:
+ name: publish to nuget.org
+ needs: [dotnet, compatibility, psmux]
+ runs-on: ubuntu-latest
+
+ # The policy on nuget.org names this environment, so a workflow that ran
+ # outside it cannot exchange a token. It is also where a required reviewer
+ # and a tag restriction belong.
+ environment: nuget
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ permissions:
+ contents: read
+ # Lets the job ask GitHub for the short-lived token nuget.org trades for
+ # a one-hour API key. Nothing else here needs a credential.
+ id-token: write
+ attestations: write
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # SourceLink points a debugger at the commit that produced the
+ # assembly, which a shallow checkout does not have.
+ fetch-depth: 0
+
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
+ with:
+ global-json-file: global.json
+
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+
+ - name: Download the packages proved by the gate
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: packages
path: artifacts/packages
- if-no-files-found: error
+
+ - name: Inspect the packages
+ run: uv run python eng/parity/inspect_packages.py
- name: NuGet login
# The key this returns lasts an hour, so it is asked for immediately
@@ -145,12 +249,9 @@ jobs:
subject-path: 'artifacts/packages/*.nupkg'
- name: Push
- # The matching .snupkg is pushed alongside each package automatically,
- # which is what puts the symbols on the NuGet symbol server.
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
run: >
dotnet nuget push "artifacts/packages/*.nupkg"
--api-key "${NUGET_API_KEY}"
--source https://api.nuget.org/v3/index.json
- --skip-duplicate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index edc0a56..6789ab4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,59 @@ Versions follow [Semantic Versioning](https://semver.org). During alpha the
public API can change in any release with no deprecation period — pin an exact
version.
+## [Unreleased]
+
+### Added
+
+- **An experimental query-only psmux preview for native Windows and WSL
+ interop.** A separate
+ analyzer-clean API reads one isolated session, its windows and panes, and pane
+ text without exposing tmux lifecycle, mutation, chaining, control mode, or
+ MCP behavior that psmux cannot preserve. The client commit, clean banner, and
+ executable SHA-256 are pinned; publishing that exact artifact and completing
+ native/WSL runtime verification remain release prerequisites. The PowerShell
+ harness owns a fresh data directory and refuses ambiguous cleanup. See [the
+ exact trust and compatibility boundary](docs/psmux.md).
+- **Explicit control-stream loss reporting.** A bounded event reader now gets a
+ `TmuxEventsDroppedEvent` with per-report and cumulative counts instead of
+ silently missing notifications when it falls behind.
+
+### Changed
+
+- Generated API reference pages now contain only the public LibTmux surface,
+ use each partial type's canonical summary, and are checked in CI alongside
+ the public API, capability, snippet, and MCP catalogs.
+- MCP tail cursors are authenticated, bounded in size, and tied to the exact
+ endpoint, server generation, and pane. Search and every serialized tool
+ result now obey hard global line and UTF-8 byte ceilings.
+- MCP Tasks admit at most eight active executions, retain a bounded result set,
+ and apply only to waits and job collection.
+- **The `LibTmux` package README states tmux, framework and platform support
+ in one table**, including what the Windows psmux preview reads and refuses.
+- **The `LibTmux.Mcp` package README is an onboarding path.** How the server
+ behaves — what to wait on, what bounds a result, which tools a tier
+ registers, what a subscription holds open — moved to
+ [`docs/mcp/`](docs/mcp/README.md), beside the generated tool reference.
+- **`tmux_run` no longer answers as an MCP task.**
+
+ - Previous behaviour: a task-capable client received a task handle and
+ collected the result later.
+ - New behaviour: the call blocks until the command exits, for every client.
+ - Reason: an SDK task carries no durable tmux handle, so a client that
+ disconnected lost work it believed was parked.
+ - Recommended action: use `tmux_start_job` and `tmux_job`. tmux owns that
+ handle, so it survives a disconnect.
+
+### Fixed
+
+- Control clients clean up a failed or cancelled attach without replacing its
+ primary error. MCP hierarchy and pane-activity streams keep independent
+ subscribers, recover after a stream ends, and preserve nullable structured
+ fields required by their advertised schemas.
+- MCP background jobs retain their originating endpoint and generation, cap
+ concurrent state, clean up failed starts, serialize collection, and drain
+ watcher tasks during disposal instead of leaking or cross-routing work.
+
## [0.0.0-alpha.8] — 2026-08-22
No behaviour change. `git diff v0.0.0-alpha.7..v0.0.0-alpha.8 -- src/` touches
@@ -60,12 +113,13 @@ IntelliSense text, and the page on nuget.org.
### Changed
- **`LibTmux.Mcp` is a different server.** It offered five tools; it now offers
- 42, across three safety tiers, with six `tmux://` resources and four workflow
- prompts. Every tool answers a typed record with a JSON output schema rather
- than prose, so a client destructures a result instead of parsing one. The
- tool names all changed — `list_tmux` and friends are gone in favour of
- `tmux_hierarchy`, `tmux_run` and the rest. [The reference](docs/mcp/tools.md)
- is generated from the server, so it cannot describe a surface that is absent.
+ 42, across three safety tiers, with four fixed `tmux://` resources, two
+ resource templates and four workflow prompts. Every shaped tool answers a
+ typed record with a JSON output schema; `tmux_display_message` returns the raw
+ expanded format text it was asked for. The tool names all changed —
+ `list_tmux` and friends are gone in favour of `tmux_hierarchy`, `tmux_run` and
+ the rest. [The reference](docs/mcp/tools.md) is generated from the server, so
+ it cannot describe a surface that is absent.
### Added
@@ -268,6 +322,7 @@ it is: a published version can never be deleted from nuget.org, only unlisted.
- `LibTmux.Workspace` — sessions from tmuxp workspace files.
- `LibTmux.Mcp` — a Model Context Protocol server, installed as a .NET tool.
+[Unreleased]: https://github.com/libtmux/libtmux-dotnet/compare/v0.0.0-alpha.8...HEAD
[0.0.0-alpha.8]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.8
[0.0.0-alpha.7]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.7
[0.0.0-alpha.6]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.6
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0d173b2..7298ed0 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -18,14 +18,10 @@
-
+
-
+
diff --git a/README.md b/README.md
index 78aff57..baecfa5 100644
--- a/README.md
+++ b/README.md
@@ -35,13 +35,14 @@ environment, script a workspace, harness a TUI in tests, or give an assistant
hands on a terminal.
**Look elsewhere if you want** a terminal emulator (this drives tmux, it does
-not draw), a Windows-native solution (tmux is Unix only), or a process
-launcher — `Process.Start` is right there.
+not draw), unrestricted Windows parity, or a process launcher — `Process.Start`
+is right there. Native Windows has a bounded, query-only
+[`Psmux*` preview](docs/psmux.md).
**What you get that a shell wrapper does not:** typed entities with real IDs,
one dispatch model per workload (below), a version model that tells you when a
flag does not exist on the tmux you are on rather than failing oddly, and
-documented examples that are executed against live tmux in CI.
+documented ordinary-tmux examples that are executed against live tmux in CI.
## Packages
@@ -261,23 +262,26 @@ $ dotnet tool install --global LibTmux.Mcp --prerelease
{ "mcpServers": { "tmux": { "command": "libtmux-mcp" } } }
```
-It exposes 42 tools across three safety tiers, six `tmux://` resources and four
-workflow prompts — [the full reference](docs/mcp/tools.md) is generated from the
-server itself. Pass a socket name as its first argument to drive a server other
-than the ambient one, which is what a sandbox wants.
+It exposes 42 tools across three safety tiers, four fixed `tmux://` resources,
+two resource templates and four workflow prompts — [the full
+reference](docs/mcp/tools.md) is generated from the server itself. Pass a socket
+name as its first argument to drive a server other than the ambient one, which
+is what a sandbox wants.
What it is built around is that an assistant should never get stuck and never
-waste context. Nothing polls: `tmux_run` returns the shell's real exit status,
+waste context. `tmux_run` returns the shell's real exit status,
`tmux_start_job` hands back a handle for work that takes minutes, and
-`tmux_wait_for_text` sleeps on tmux's own control-mode stream until the pane
-prints. Nothing returns unbounded output: every capture keeps the newest lines
-and reports what it dropped. `LIBTMUX_SAFETY` decides which tier is registered,
-and a tool above it never reaches the model's list.
+`tmux_wait_for_text` normally wakes from tmux's control-mode stream, with a
+bounded polling fallback when that stream cannot start. Nothing returns
+unbounded output: every capture keeps the newest lines and reports what it
+dropped. `LIBTMUX_SAFETY` decides which tier is registered, and a tool above it
+never reaches the model's list.
[Full instructions](src/LibTmux.Mcp/README.md).
## Documentation
- [Choosing a mode](docs/modes/matrix.md) — the three dispatch modes, measured
+- [Windows psmux preview](docs/psmux.md) — what it reads, and what it refuses
- [API reference](docs/api/README.md) — rendered from the doc comments
- [tmux MCP tools](docs/mcp/tools.md) — every tool, tier and resource, generated
- [Public API](docs/public-api.md) — the reviewed, approved surface
@@ -292,8 +296,8 @@ and a tool above it never reaches the model's list.
|---|---|
| tmux | 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b |
| .NET | net8.0, net10.0 |
-| OS | Linux, macOS. Windows is unsupported — tmux does not run there |
-| Trimming | trim- and AOT-safe, proven by publishing and running it |
+| OS | Linux, macOS. The bounded [`Psmux*` native-Windows and WSL query preview](docs/psmux.md) is experimental; its release gate runs both paths on net8.0 and net10.0 |
+| Trimming / NativeAOT | `LibTmux` core is analyzer-gated and its smoke app is published and run for `linux-x64` on net8.0 and net10.0. That proof does not cover the other packages, macOS, or native Windows/psmux |
## License
diff --git a/benchmarks/LibTmux.Benchmarks/packages.lock.json b/benchmarks/LibTmux.Benchmarks/packages.lock.json
index fbf83d8..9bb250d 100644
--- a/benchmarks/LibTmux.Benchmarks/packages.lock.json
+++ b/benchmarks/LibTmux.Benchmarks/packages.lock.json
@@ -280,8 +280,8 @@
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
- "resolved": "10.0.11",
- "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg=="
+ "resolved": "8.0.0",
+ "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
@@ -296,11 +296,10 @@
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
- "resolved": "10.0.11",
- "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==",
+ "resolved": "8.0.0",
+ "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==",
"dependencies": {
- "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
- "System.Diagnostics.DiagnosticSource": "10.0.11"
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0"
}
},
"Microsoft.Extensions.Options": {
@@ -340,11 +339,6 @@
"resolved": "9.0.0",
"contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w=="
},
- "System.Diagnostics.DiagnosticSource": {
- "type": "Transitive",
- "resolved": "10.0.11",
- "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ=="
- },
"System.Management": {
"type": "Transitive",
"resolved": "9.0.5",
@@ -364,7 +358,7 @@
"libtmux": {
"type": "Project",
"dependencies": {
- "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )"
+ "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
}
}
}
diff --git a/docs/README.md b/docs/README.md
index 4a65ed4..17918be 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,11 +1,12 @@
# LibTmux
-> **Alpha.** The behaviour here is proven against tmux 3.2a through 3.7b on
-> every commit; the shape of the API is what is not settled yet.
+> **Alpha.** Ordinary tmux behavior is gated against tmux 3.2a through 3.7b;
+> the API shape is not settled. The [psmux preview](psmux.md) is experimental
+> and query-only.
-A .NET class library for tmux. Every call reaches a real tmux server, and
-which of the three execution modes you are in is visible where the call
-starts.
+A .NET class library for tmux. The three ordinary execution modes reach a real
+tmux server, and the mode is visible where the call starts. The psmux preview
+reads one isolated session on Windows.
## Start here
@@ -16,6 +17,7 @@ machine. [Benchmarks](benchmarks/README.md) holds the recorded runs behind it.
- [One-shot](modes/one-shot.md) — one command, one materialized object
- [Control mode](modes/control-mode.md) — one client, streamed events
- [Chaining](modes/chaining.md) — many commands, one invocation
+- [psmux preview](psmux.md) — one isolated session, query only
## Reference
@@ -23,8 +25,11 @@ machine. [Benchmarks](benchmarks/README.md) holds the recorded runs behind it.
compiler emits, so nothing can be documented there and absent from the
library.
-[tmux MCP tools](mcp/tools.md) is generated by asking the server what it
-advertises, so it cannot describe a surface that is not there.
+[tmux MCP server](mcp/README.md) covers what to wait on rather than poll, what
+bounds every result, which tools each safety tier registers, and what a
+subscription holds open. [The tool reference](mcp/tools.md) beside it is
+generated by asking the server what it advertises, so it cannot describe a
+surface that is not there.
## Packages
@@ -52,8 +57,8 @@ and each record has a validator that fails when the code disagrees.
symbol went
- [Decisions](decisions/) — why the transport, object model, query
catalog, and public API are shaped the way they are
-- [Quality bar](quality-bar.md) — what "good" is claimed to mean here, with
- the evidence for each claim and a script that re-measures it
+- [Quality-bar snapshot](quality-bar.md) — an archived scored tree and the
+ script that prints raw measures for a current one
Decisions 0001 to 0003 quote the commands that produced their evidence, and
those ran while this project was a directory inside another repository. Their
diff --git a/docs/api/README.md b/docs/api/README.md
index 6904ec2..82bfc1a 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -1,7 +1,7 @@
# API reference
-Generated from the XML documentation the compiler emits, so every entry
-here is the doc comment on the member itself. Regenerate with
+Generated from compiler XML summaries and gated by the approved public
+contract, so documented internal helpers never render. Regenerate with
`uv run python eng/docs/render_api_reference.py`.
See [choosing a mode](../modes/matrix.md) for how the three execution
@@ -15,15 +15,13 @@ modes differ.
| `LibTmux.BindKeyRequest` | Describes one bind-key invocation. |
| `LibTmux.CapturePanePosition` | Names one end of a capture range. |
| `LibTmux.CapturePaneRequest` | Describes one capture-pane invocation. |
-| `LibTmux.CapturedRelation` | Creates captured and uncaptured relations with inferred types. |
-| `LibTmux.CapturedRelation`1` | Holds the children a snapshot captured for one relation. |
+| ``LibTmux.CapturedRelation`1`` | Holds the children a snapshot captured for one relation. |
| `LibTmux.ChooseTreeRequest` | Describes one choose-tree invocation. |
| `LibTmux.ChooseTreeSort` | Names how a chooser orders its rows. |
| `LibTmux.Client` | Identifies a client and resolves what it is looking at. |
| `LibTmux.ClientAttachment` | What one client is looking at. |
| `LibTmux.CommandPromptRequest` | Describes one command-prompt invocation. |
| `LibTmux.ConfirmBeforeRequest` | Describes one confirm-before invocation. |
-| `LibTmux.ControlModeSession` | Reads one tmux control client and correlates what it says. |
| `LibTmux.CopyModeRequest` | Describes one copy-mode invocation. |
| `LibTmux.DisplayMenuRequest` | Describes one display-menu invocation. |
| `LibTmux.DisplayMessageRequest` | Describes one display-message invocation. |
@@ -49,7 +47,7 @@ modes differ.
| `LibTmux.OwnedServerScope` | Owns a server and stops it when disposed. |
| `LibTmux.OwnedSessionScope` | Owns a session and stops it when disposed. |
| `LibTmux.OwnedWindowScope` | Owns a window and stops it when disposed. |
-| `LibTmux.Pane` | Provides raw command execution for a tmux pane. |
+| `LibTmux.Pane` | Represents an immutable pane handle and snapshot. |
| `LibTmux.PaneDirection` | Defines pane placement directions. |
| `LibTmux.PaneId` | Represents a generation-independent tmux pane identifier. |
| `LibTmux.PaneInputMode` | Names whether a pane accepts input. |
@@ -59,6 +57,12 @@ modes differ.
| `LibTmux.PipePaneRequest` | Describes one pipe-pane invocation. |
| `LibTmux.PopupCloseMode` | Names when a popup closes on its own. |
| `LibTmux.PromptType` | What a command prompt is asking for. |
+| `LibTmux.PsmuxCaptureOptions` | Chooses the psmux pane text that can be captured safely. |
+| `LibTmux.PsmuxConnectionOptions` | Configures the bounded psmux query preview. |
+| `LibTmux.PsmuxPane` | An immutable observation of one psmux pane. |
+| `LibTmux.PsmuxServer` | Reads one isolated, single-session psmux namespace. |
+| `LibTmux.PsmuxSession` | An immutable observation of the sole psmux session. |
+| `LibTmux.PsmuxWindow` | An immutable observation of one psmux window. |
| `LibTmux.Query.AndNode` | The conjunction of ordered operands. |
| `LibTmux.Query.BooleanConstant` | A boolean literal. |
| `LibTmux.Query.ComparisonNode` | An ordering or equality comparison. |
@@ -76,17 +80,14 @@ modes differ.
| `LibTmux.Query.QueryDocument` | One translated query predicate and its wire schema. |
| `LibTmux.Query.QueryEdgeParser` | Parses the one legacy lookup spelling this port still carries. |
| `LibTmux.Query.QueryExtensions` | Translates, compiles, and applies declarative query predicates. |
-| `LibTmux.Query.QueryInterpreter` | Evaluates a query document against in-memory elements. |
| `LibTmux.Query.QueryNode` | One node of a translated query predicate. |
| `LibTmux.Query.QueryQuantifier` | Names how a quantifier folds a relation. |
| `LibTmux.Query.QueryStringOperation` | Names a string comparison, always ordinal. |
| `LibTmux.Query.QueryTarget` | Names the tmux object a field or quantifier reads. |
-| `LibTmux.Query.QueryTranslator` | Translates a supported expression into a query document. |
| `LibTmux.Query.RegexNode` | A constant-pattern regular expression match. |
| `LibTmux.Query.StringConstant` | A string literal. |
| `LibTmux.Query.StringNode` | An ordinal string comparison. |
| `LibTmux.Query.TypedIdConstant` | A typed tmux identifier literal. |
-| `LibTmux.RelationReader` | Reads one live relation and rebuilds owned entity handles. |
| `LibTmux.ResizeDirection` | Defines pane resize directions. |
| `LibTmux.ResizePaneRequest` | Describes one resize-pane invocation. |
| `LibTmux.ResizeWindowRequest` | Describes one resize-window invocation. |
@@ -96,28 +97,25 @@ modes differ.
| `LibTmux.SelectLayoutRequest` | Describes one select-layout invocation. |
| `LibTmux.SelectPaneRequest` | Describes one select-pane invocation. |
| `LibTmux.SendKeysRequest` | Describes one send-keys invocation. |
-| `LibTmux.Server` | Runs tmux-side filters and returns the surviving objects. |
+| `LibTmux.Server` | Represents an immutable server handle and snapshot. |
| `LibTmux.ServerAccessRequest` | Describes one server-access invocation. |
| `LibTmux.ServerConnectionOptions` | Configures a tmux server connection without mutating process-wide state. |
| `LibTmux.ServerGeneration` | Identifies one tmux daemon generation. |
-| `LibTmux.ServerSnapshot` | Holds one point-in-time read of a tmux server's hierarchy. |
-| `LibTmux.Session` | Provides raw command execution for a tmux session. |
+| `LibTmux.Session` | Represents an immutable session handle and snapshot. |
| `LibTmux.SessionId` | Represents a generation-independent tmux session identifier. |
| `LibTmux.SessionWindowEdge` | Places one window at one index inside one session. |
| `LibTmux.SetHookRequest` | Describes one set-hook invocation. |
| `LibTmux.SetHooksRequest` | Describes setting several entries of one hook at once. |
| `LibTmux.SetOptionRequest` | Describes one set-option invocation. |
| `LibTmux.ShowMessagesMode` | What show-messages should list. |
-| `LibTmux.SnapshotCollectionExtensions` | Indexes captured collections without leaving memory. |
| `LibTmux.SnapshotDepth` | Names how far down the tmux hierarchy a snapshot captured. |
-| `LibTmux.SnapshotLookup`2` | Indexes a captured collection by a stable key. |
| `LibTmux.SplitPaneRequest` | Describes one split-window invocation. |
| `LibTmux.StaleServerGenerationException` | Reports a stale server generation. |
| `LibTmux.SwapPaneRequest` | Describes one swap-pane invocation. |
| `LibTmux.Testing.TemporaryHierarchyScope` | A server, session, window, and pane a test owns together. |
| `LibTmux.Testing.TemporaryServerScope` | Creates a throwaway server for a test and stops it afterwards. |
-| `LibTmux.Testing.TemporarySessionScope` | Creates a throwaway session for a test and stops it afterwards. |
-| `LibTmux.Testing.TemporaryWindowScope` | Creates a throwaway window for a test and stops it afterwards. |
+| `LibTmux.Testing.TemporarySessionScope` | Owns a throwaway session and any private server created with it. |
+| `LibTmux.Testing.TemporaryWindowScope` | Owns a throwaway window and any private session and server created with it. |
| `LibTmux.Testing.TestEnvironment` | The directory and variables a test's tmux runs with. |
| `LibTmux.Testing.TmuxNameGenerator` | Makes names no other test is using. |
| `LibTmux.Testing.TmuxTestContext` | A tmux server a test owns, and the environment it runs in. |
@@ -133,9 +131,11 @@ modes differ.
| `LibTmux.TmuxCommandException` | Reports a command-policy failure. |
| `LibTmux.TmuxCommandNotFoundException` | Reports a missing tmux executable. |
| `LibTmux.TmuxCommandResult` | Contains the inspectable result of one raw tmux command. |
+| `LibTmux.TmuxDispatchState` | Says whether a failed command reached tmux, which is what decides if retrying is safe. |
| `LibTmux.TmuxEnvironment` | The environment tmux gives to the processes it spawns. |
| `LibTmux.TmuxEnvironmentEntry` | One variable in a tmux environment. |
| `LibTmux.TmuxEvent` | One thing a tmux control client reported without being asked. |
+| `LibTmux.TmuxEventsDroppedEvent` | Reports notifications discarded because the bounded event buffer was full. |
| `LibTmux.TmuxExitEvent` | The control client ended. |
| `LibTmux.TmuxHook` | One hook and every command it runs. |
| `LibTmux.TmuxHookEntry` | One command a hook runs, and where it sits in the order. |
@@ -163,16 +163,12 @@ modes differ.
| `LibTmux.UnsetOptionRequest` | Describes one set-option -u invocation. |
| `LibTmux.UnsupportedQueryExpressionException` | Thrown when an expression cannot be translated to a query. |
| `LibTmux.WaitForRequest` | Describes one wait-for invocation. |
-| `LibTmux.Window` | Provides raw command execution for a tmux window. |
+| `LibTmux.Window` | Represents an immutable window handle and snapshot. |
| `LibTmux.WindowDirection` | Defines relative window placement. |
| `LibTmux.WindowEntityKey` | Identifies one window as it appears inside one session. |
| `LibTmux.WindowId` | Represents a generation-independent tmux window identifier. |
| `LibTmux.WindowResizeMode` | Names how a window is resized against its clients. |
| `LibTmux.WindowRotationDirection` | Names which way a window's panes rotate. |
-| `System.Text.RegularExpressions.Generated.Utilities` | Helper methods used by generated -derived implementations. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0` | Custom -derived type for the VersionRegex method. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory` | Provides a factory for creating instances to be used by methods on . |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner` | Provides the runner that contains the custom logic implementing the specified regular expression. |
## Methods
@@ -182,9 +178,7 @@ modes differ.
| `LibTmux.BindKeyRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.String,System.String,System.Boolean)` | Initializes a key binding. |
| `LibTmux.CapturePanePosition.#ctor(System.Int32)` | Initializes a position at one line. |
| `LibTmux.CapturePaneRequest.#ctor(System.Nullable{LibTmux.CapturePanePosition},System.Nullable{LibTmux.CapturePanePosition},System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a capture request. |
-| `LibTmux.CapturedRelation.Capture``1(System.Collections.Generic.IEnumerable{``0},System.String,LibTmux.SnapshotDepth)` | Creates a captured relation over a read child sequence. |
-| `LibTmux.CapturedRelation.Uncaptured``1(System.String,LibTmux.SnapshotDepth)` | Creates a relation the snapshot did not read. |
-| `LibTmux.CapturedRelation`1.OrEmpty` | Returns the captured children, or an empty list when unread. |
+| ``LibTmux.CapturedRelation`1.OrEmpty`` | Returns the captured children, or an empty list when unread. |
| `LibTmux.ChooseTreeRequest.#ctor(System.Boolean,System.Boolean,System.String,LibTmux.UnsafeTmuxFilter,System.Nullable{LibTmux.ChooseTreeSort},System.Boolean,System.Boolean)` | Initializes a tree-chooser request. |
| `LibTmux.Client.GetAsync(LibTmux.Server,System.String,System.Threading.CancellationToken)` | Reads one client by name. |
| `LibTmux.Client.GetAttachedPaneAsync(System.Threading.CancellationToken)` | Reads the pane this client has active now. |
@@ -195,7 +189,6 @@ modes differ.
| `LibTmux.ClientAttachment.#ctor(LibTmux.Session,LibTmux.Window,LibTmux.Pane)` | What one client is looking at. |
| `LibTmux.CommandPromptRequest.#ctor(System.String,System.String,System.String,System.String,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Nullable{LibTmux.PromptType},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a command prompt. |
| `LibTmux.ConfirmBeforeRequest.#ctor(System.Collections.Generic.IReadOnlyList{System.String},System.String,System.String,System.Boolean,System.String)` | Initializes a confirmation. |
-| `LibTmux.ControlModeSession.WaitForReadyAsync(System.Threading.CancellationToken)` | Waits until tmux has answered its own attach. |
| `LibTmux.CopyModeRequest.#ctor(System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String)` | Initializes a copy-mode request. |
| `LibTmux.DisplayMenuRequest.#ctor(System.Collections.Generic.IReadOnlyList{LibTmux.TmuxMenuItem},System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.Boolean,System.Boolean)` | Initializes a menu. |
| `LibTmux.DisplayMessageRequest.#ctor(System.String,System.Boolean,System.String,System.Boolean,System.Boolean,System.Boolean,System.String,System.Nullable{System.TimeSpan},System.Boolean,System.Boolean)` | Initializes a display-message request. |
@@ -207,7 +200,8 @@ modes differ.
| `LibTmux.IControlModeSession.SendAsync(System.String,System.Threading.CancellationToken)` | Runs one command on this client and reads what it answered. |
| `LibTmux.IfShellRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Collections.Generic.IReadOnlyList{System.String},System.Boolean,System.String)` | Initializes a conditional command. |
| `LibTmux.IncompleteSnapshotException.#ctor(System.String,LibTmux.SnapshotDepth)` | Initializes the exception for one uncaptured relation. |
-| `LibTmux.LibTmuxException.#ctor(System.String,System.Exception)` | Initializes a LibTmux exception. |
+| `LibTmux.LibTmuxException.#ctor(System.String,LibTmux.TmuxDispatchState,System.Exception)` | Initializes a LibTmux exception that knows whether tmux ran the command. |
+| `LibTmux.LibTmuxException.#ctor(System.String,System.Exception)` | Initializes a LibTmux exception whose dispatch state is unknown. |
| `LibTmux.LinkWindowRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.WindowDirection},System.Boolean,System.Boolean)` | Initializes a window-link request. |
| `LibTmux.ListBuffersRequest.#ctor(System.String,LibTmux.UnsafeTmuxFilter)` | Initializes a buffer listing. |
| `LibTmux.ListHooksRequest.#ctor(System.Nullable{LibTmux.OptionScope},System.Boolean)` | Initializes a request for every hook in a scope. |
@@ -216,14 +210,10 @@ modes differ.
| `LibTmux.NewPaneRequest.#ctor(System.String,System.String,System.Boolean,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Boolean,System.Boolean,System.String,System.String,System.String,System.String,System.Boolean)` | Initializes a pane-creation request. |
| `LibTmux.NewSessionRequest.#ctor(System.String,System.Boolean,System.Boolean,System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Boolean,System.Boolean,System.String)` | Initializes a session-creation request. |
| `LibTmux.NewWindowRequest.#ctor(System.String,System.String,System.Boolean,System.String,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Nullable{LibTmux.WindowDirection},System.String,System.Boolean,System.Boolean)` | Initializes a window-creation request. |
-| `LibTmux.NewWindowRequest.WithTargetWindow(System.String)` | Returns this request aimed at one window. |
| `LibTmux.OwnedServerScope.DisposeAsync` | Stops the owned server. |
| `LibTmux.OwnedSessionScope.DisposeAsync` | Stops the owned session. |
| `LibTmux.OwnedWindowScope.DisposeAsync` | Stops the owned window. |
| `LibTmux.Pane.BreakAsync(System.String,System.Boolean,System.Threading.CancellationToken)` | Moves this pane out into a window of its own. |
-| `LibTmux.Pane.BuildDisplayPopupArguments(LibTmux.DisplayPopupRequest)` | Builds the arguments a paste request sends. |
-| `LibTmux.Pane.BuildNewPaneArguments(LibTmux.NewPaneRequest)` | Builds the arguments a chooser request sends. |
-| `LibTmux.Pane.BuildRespawnPaneArguments(LibTmux.RespawnRequest)` | Builds the arguments a copy-mode request sends. |
| `LibTmux.Pane.CaptureAsync(LibTmux.CapturePaneRequest,System.Threading.CancellationToken)` | Reads the pane's contents. |
| `LibTmux.Pane.CaptureToBufferAsync(System.String,LibTmux.CapturePaneRequest,System.Threading.CancellationToken)` | Captures the pane's contents into a tmux buffer. |
| `LibTmux.Pane.ChooseBufferAsync(System.Threading.CancellationToken)` | Opens the buffer chooser in this pane. |
@@ -266,6 +256,17 @@ modes differ.
| `LibTmux.PaneId.TryParse(System.String,LibTmux.PaneId@)` | Tries to parse a prefixed pane identifier. |
| `LibTmux.PasteBufferRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean,System.String,System.Boolean)` | Initializes a buffer-paste request. |
| `LibTmux.PipePaneRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean)` | Initializes a pane-piping request. |
+| `LibTmux.PsmuxCaptureOptions.#ctor(System.Nullable{LibTmux.CapturePanePosition},System.Nullable{LibTmux.CapturePanePosition},System.Boolean,System.Boolean)` | Initializes a bounded psmux capture. |
+| `LibTmux.PsmuxConnectionOptions.#ctor(System.String,System.String,System.String,System.String,Microsoft.Extensions.Logging.ILogger)` | Initializes one explicit psmux endpoint. |
+| `LibTmux.PsmuxPane.CaptureAsync(LibTmux.PsmuxCaptureOptions,System.Threading.CancellationToken)` | Reads this pane's text through the audited capture subset. |
+| `LibTmux.PsmuxServer.ConnectAsync(LibTmux.PsmuxConnectionOptions,System.Threading.CancellationToken)` | Connects to a separately provisioned psmux namespace. |
+| `LibTmux.PsmuxServer.GetPanesAsync(System.Threading.CancellationToken)` | Reads every pane in the sole session. |
+| `LibTmux.PsmuxServer.GetSessionAsync(System.Threading.CancellationToken)` | Reads the sole visible session. |
+| `LibTmux.PsmuxServer.GetWindowsAsync(System.Threading.CancellationToken)` | Reads every window in the sole session. |
+| `LibTmux.PsmuxServer.RefreshAsync(System.Threading.CancellationToken)` | Reconnects and returns a fresh server observation. |
+| `LibTmux.PsmuxSession.GetPanesAsync(System.Threading.CancellationToken)` | Reads the session's current panes. |
+| `LibTmux.PsmuxSession.GetWindowsAsync(System.Threading.CancellationToken)` | Reads the session's current windows. |
+| `LibTmux.PsmuxWindow.GetPanesAsync(System.Threading.CancellationToken)` | Reads the window's current panes. |
| `LibTmux.Query.AndNode.#ctor(System.Collections.Generic.IReadOnlyList{LibTmux.Query.QueryNode})` | Initializes a conjunction. |
| `LibTmux.Query.BooleanConstant.#ctor(System.Boolean)` | A boolean literal. |
| `LibTmux.Query.ComparisonNode.#ctor(LibTmux.Query.QueryComparison,LibTmux.Query.QueryNode,LibTmux.Query.QueryNode)` | An ordering or equality comparison. |
@@ -279,10 +280,10 @@ modes differ.
| `LibTmux.Query.QuantifierNode.#ctor(LibTmux.Query.QueryQuantifier,LibTmux.Query.FieldNode,LibTmux.Query.QueryNode)` | A quantifier over a relation field. |
| `LibTmux.Query.QueryDocument.#ctor(System.String,System.Int32,LibTmux.Query.QueryTarget,LibTmux.Query.QueryNode)` | One translated query predicate and its wire schema. |
| `LibTmux.Query.QueryEdgeParser.ParseNameContains(LibTmux.Query.QueryTarget,System.String)` | Parses a name__contains lookup into a query document. |
-| `LibTmux.Query.QueryExtensions.Compile``1(LibTmux.Query.QueryDocument)` | Compiles a document into an in-memory predicate. |
-| `LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument)` | Filters a snapshot with an already translated document. |
-| `LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})` | Filters a snapshot with a declarative predicate. |
-| `LibTmux.Query.QueryExtensions.Translate``1(System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})` | Translates an expression into a wire document. |
+| ```LibTmux.Query.QueryExtensions.Compile``1(LibTmux.Query.QueryDocument)``` | Compiles a document into an in-memory predicate. |
+| ```LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument)``` | Filters a snapshot with an already translated document. |
+| ```LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})``` | Filters a snapshot with a declarative predicate. |
+| ```LibTmux.Query.QueryExtensions.Translate``1(System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})``` | Translates an expression into a wire document. |
| `LibTmux.Query.RegexNode.#ctor(LibTmux.Query.QueryNode,System.String,System.String,System.Text.RegularExpressions.RegexOptions)` | A constant-pattern regular expression match. |
| `LibTmux.Query.StringConstant.#ctor(System.String)` | A string literal. |
| `LibTmux.Query.StringNode.#ctor(LibTmux.Query.QueryStringOperation,LibTmux.Query.QueryNode,LibTmux.Query.QueryNode)` | An ordinal string comparison. |
@@ -296,12 +297,6 @@ modes differ.
| `LibTmux.SendKeysRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String,System.Nullable{System.Int32},System.Boolean,System.Boolean,System.String,System.Boolean)` | Initializes a key-sending request. |
| `LibTmux.Server.AttachSessionAsync(LibTmux.AttachSessionRequest,System.Threading.CancellationToken)` | Attaches a client to a session on this server. |
| `LibTmux.Server.BindKeyAsync(LibTmux.BindKeyRequest,System.Threading.CancellationToken)` | Binds a key to a tmux command. |
-| `LibTmux.Server.BuildCommandPromptArguments(LibTmux.CommandPromptRequest)` | Builds the arguments a prompt request sends. |
-| `LibTmux.Server.BuildConfirmBeforeArguments(LibTmux.ConfirmBeforeRequest)` | Builds the arguments a confirmation request sends. |
-| `LibTmux.Server.BuildDisplayMenuArguments(LibTmux.DisplayMenuRequest)` | Builds the arguments a menu request sends. |
-| `LibTmux.Server.BuildDisplayMessageArguments(LibTmux.DisplayMessageRequest)` | Builds the arguments a message request sends. |
-| `LibTmux.Server.BuildRunShellArguments(LibTmux.RunShellRequest)` | Builds the arguments a shell request sends. |
-| `LibTmux.Server.BuildServerAccessArguments(LibTmux.ServerAccessRequest)` | Builds the arguments an access request sends. |
| `LibTmux.Server.CaptureSnapshotAsync(LibTmux.SnapshotDepth,System.Threading.CancellationToken)` | Reads the server and answers a handle carrying what it found. |
| `LibTmux.Server.Chain` | Begins a chain that runs its commands in one tmux invocation. |
| `LibTmux.Server.ClearPromptHistoryAsync(System.Nullable{LibTmux.PromptType},System.Threading.CancellationToken)` | Forgets what has been typed at command prompts. |
@@ -362,7 +357,6 @@ modes differ.
| `LibTmux.ServerAccessRequest.#ctor(System.String,System.String,System.Boolean,System.Boolean,System.Boolean)` | Initializes an access change. |
| `LibTmux.ServerConnectionOptions.#ctor(System.String,System.String,System.String,System.Func{System.String},System.String,LibTmux.TmuxColorMode,System.Func{LibTmux.Server,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask},System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},Microsoft.Extensions.Logging.ILogger)` | Initializes connection options. |
| `LibTmux.ServerGeneration.#ctor(System.Int32,System.Int64)` | Initializes a server generation. |
-| `LibTmux.ServerSnapshot.CaptureAsync(LibTmux.Server,LibTmux.SnapshotDepth,System.Threading.CancellationToken)` | Reads one server hierarchy to the requested depth. |
| `LibTmux.Session.AttachAsync(LibTmux.AttachSessionRequest,System.Threading.CancellationToken)` | Attaches a client to this session. |
| `LibTmux.Session.CreateOwnedWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window in this session and takes ownership of it. |
| `LibTmux.Session.CreateWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window in this session. |
@@ -391,14 +385,9 @@ modes differ.
| `LibTmux.SetHookRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request to set one hook. |
| `LibTmux.SetHooksRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyDictionary{System.Int32,System.String},System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean)` | Initializes a request to set several entries of one hook. |
| `LibTmux.SetOptionRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request to set one option. |
-| `LibTmux.SnapshotCollectionExtensions.ToLookupByKey``2(System.Collections.Generic.IEnumerable{``1},System.Func{``1,``0})` | Indexes a captured collection by a required key. |
-| `LibTmux.SnapshotLookup`2.TryGetValue(`0,`1@)` | Tries to get the element with the given key. |
| `LibTmux.SplitPaneRequest.#ctor(System.String,System.String,System.Boolean,System.Nullable{LibTmux.PaneDirection},System.Boolean,System.Boolean,System.String,System.String,System.Nullable{System.Int32},System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Boolean,System.String,System.String,System.String,System.String,System.Boolean)` | Initializes a pane-split request. |
| `LibTmux.StaleServerGenerationException.#ctor(System.String,LibTmux.ServerGeneration,LibTmux.ServerGeneration,System.Exception)` | Initializes a stale-generation exception. |
| `LibTmux.SwapPaneRequest.#ctor(System.String,System.Nullable{LibTmux.PaneSwapDirection},System.Boolean,System.Boolean)` | Initializes a pane-swap request. |
-| `LibTmux.Testing.TemporaryServerScope.StartAsync(LibTmux.ServerConnectionOptions,System.Threading.CancellationToken)` | Starts a temporary server on its own socket. |
-| `LibTmux.Testing.TemporarySessionScope.StartAsync(LibTmux.Server,LibTmux.NewSessionRequest,System.Threading.CancellationToken)` | Creates a temporary session on a running server. |
-| `LibTmux.Testing.TemporaryWindowScope.StartAsync(LibTmux.Session,LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a temporary window in a session. |
| `LibTmux.Testing.TestEnvironment.#ctor(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String})` | Initializes a test environment. |
| `LibTmux.Testing.TestEnvironment.WithVariable(System.String,System.String)` | Answers a copy that also sets one variable. |
| `LibTmux.Testing.TestEnvironment.WithoutVariable(System.String)` | Answers a copy that removes one variable. |
@@ -417,7 +406,7 @@ modes differ.
| `LibTmux.Testing.TmuxTestFactory.CreateWindowAsync(LibTmux.Testing.TmuxTestOptions,System.Threading.CancellationToken)` | Starts a server, a session, and a window, all owned by this test. |
| `LibTmux.Testing.TmuxTestOptions.#ctor(LibTmux.ServerConnectionOptions,System.Nullable{System.TimeSpan},System.Nullable{System.TimeSpan},System.String)` | Initializes test options. |
| `LibTmux.Testing.TmuxWait.UntilAsync(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{System.Boolean}},System.TimeSpan,System.TimeSpan,System.Boolean,System.Threading.CancellationToken)` | Waits until a probe reports the state was reached. |
-| `LibTmux.Testing.TmuxWait.UntilAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{``0}},System.Func{``0,System.Boolean},System.TimeSpan,System.TimeSpan,System.Threading.CancellationToken)` | Waits until a reading satisfies a predicate, and answers it. |
+| ```LibTmux.Testing.TmuxWait.UntilAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{``0}},System.Func{``0,System.Boolean},System.TimeSpan,System.TimeSpan,System.Threading.CancellationToken)``` | Waits until a reading satisfies a predicate, and answers it. |
| `LibTmux.TmuxBuffer.#ctor(System.String,System.Int64,System.String)` | Initializes one buffer. |
| `LibTmux.TmuxChain.ExecuteAsync(System.Threading.CancellationToken)` | Runs every command in one tmux invocation. |
| `LibTmux.TmuxChain.Then(LibTmux.TmuxCommand)` | Adds one command and returns the longer chain. |
@@ -518,14 +507,10 @@ modes differ.
| `LibTmux.TmuxEnvironment.SetAsync(System.String,System.String,System.Boolean,System.Boolean,System.Threading.CancellationToken)` | Sets one variable. |
| `LibTmux.TmuxEnvironment.UnsetAsync(System.String,System.Threading.CancellationToken)` | Forgets a variable entirely. |
| `LibTmux.TmuxEnvironmentEntry.#ctor(System.String,System.String,System.Boolean)` | Initializes one environment variable. |
+| `LibTmux.TmuxEventsDroppedEvent.#ctor(System.Int64,System.Int64)` | Reports notifications discarded because the bounded event buffer was full. |
| `LibTmux.TmuxExitEvent.#ctor(System.String)` | The control client ended. |
| `LibTmux.TmuxHook.#ctor(System.String,System.Collections.Generic.IReadOnlyList{LibTmux.TmuxHookEntry})` | Initializes one hook. |
| `LibTmux.TmuxHookEntry.#ctor(System.Int32,System.String)` | Initializes one hook entry. |
-| `LibTmux.TmuxHooks.BuildListArguments(LibTmux.ListHooksRequest)` | Builds the arguments a hook listing sends. |
-| `LibTmux.TmuxHooks.BuildRunArguments(LibTmux.HookRequest)` | Builds the arguments running a hook sends. |
-| `LibTmux.TmuxHooks.BuildSetAllArguments(LibTmux.SetHooksRequest)` | Builds every command a multi-entry hook request sends. |
-| `LibTmux.TmuxHooks.BuildSetArguments(LibTmux.SetHookRequest)` | Builds the arguments a hook request sends. |
-| `LibTmux.TmuxHooks.BuildUnsetArguments(LibTmux.HookRequest)` | Builds the arguments removing a hook sends. |
| `LibTmux.TmuxHooks.GetAllAsync(LibTmux.ListHooksRequest,System.Threading.CancellationToken)` | Reads every hook in the scope. |
| `LibTmux.TmuxHooks.GetAsync(LibTmux.HookRequest,System.Threading.CancellationToken)` | Reads one hook. |
| `LibTmux.TmuxHooks.RunAsync(LibTmux.HookRequest,System.Threading.CancellationToken)` | Runs a hook's commands now, without waiting for it to fire. |
@@ -539,10 +524,6 @@ modes differ.
| `LibTmux.TmuxOption.#ctor(System.String,LibTmux.TmuxOptionValue,System.Nullable{System.Int32})` | Initializes an option. |
| `LibTmux.TmuxOptionException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception for one rejected option. |
| `LibTmux.TmuxOptionValue.#ctor(System.String,LibTmux.TmuxOptionState,System.Nullable{System.Boolean},System.Nullable{System.Int64})` | Initializes an option value. |
-| `LibTmux.TmuxOptions.BuildGetAllArguments(LibTmux.GetOptionsRequest)` | Builds the arguments a whole-scope read sends. |
-| `LibTmux.TmuxOptions.BuildGetArguments(LibTmux.GetOptionRequest)` | Builds the arguments a named read sends. |
-| `LibTmux.TmuxOptions.BuildSetArguments(LibTmux.SetOptionRequest)` | Builds the arguments a set request sends. |
-| `LibTmux.TmuxOptions.BuildUnsetArguments(LibTmux.UnsetOptionRequest)` | Builds the arguments an unset request sends. |
| `LibTmux.TmuxOptions.GetAllAsync(LibTmux.GetOptionsRequest,System.Threading.CancellationToken)` | Reads every option in the scope. |
| `LibTmux.TmuxOptions.GetAsync(LibTmux.GetOptionRequest,System.Threading.CancellationToken)` | Reads one option. |
| `LibTmux.TmuxOptions.SetAsync(LibTmux.SetOptionRequest,System.Threading.CancellationToken)` | Sets one option. |
@@ -550,7 +531,8 @@ modes differ.
| `LibTmux.TmuxOutputEvent.#ctor(System.String,System.String)` | Bytes a pane wrote. |
| `LibTmux.TmuxPaneException.#ctor(System.String,LibTmux.PaneId,System.Exception)` | Initializes the exception for one pane. |
| `LibTmux.TmuxSessionExistsException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception for one taken session name. |
-| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Exception)` | Initializes a transport exception. |
+| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},LibTmux.TmuxDispatchState,System.Exception)` | Initializes a transport exception that knows whether tmux was started. |
+| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Exception)` | Initializes a transport exception whose dispatch state is unknown. |
| `LibTmux.TmuxVersion.#ctor(System.String)` | Initializes a tmux version. |
| `LibTmux.TmuxVersion.CheckMinimumSupportedVersionAsync(System.Boolean,System.String,System.Threading.CancellationToken)` | Checks the package minimum and optionally throws. |
| `LibTmux.TmuxVersion.CompareTo(LibTmux.TmuxVersion)` | Compares parsed tmux versions. |
@@ -580,9 +562,6 @@ modes differ.
| `LibTmux.UnsupportedQueryExpressionException.#ctor(System.String)` | Initializes the exception for one untranslatable expression. |
| `LibTmux.UnsupportedQueryExpressionException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception naming the expression it refused. |
| `LibTmux.WaitForRequest.#ctor(System.String,LibTmux.TmuxWaitMode)` | Initializes a channel request. |
-| `LibTmux.Window.BuildLinkWindowArguments(LibTmux.LinkWindowRequest)` | Builds the arguments a link request sends. |
-| `LibTmux.Window.BuildMoveWindowArguments(LibTmux.MoveWindowRequest)` | Builds the arguments a move request sends. |
-| `LibTmux.Window.BuildResizeWindowArguments(LibTmux.ResizeWindowRequest)` | Builds the arguments a layout request sends. |
| `LibTmux.Window.CreatePaneAsync(LibTmux.NewPaneRequest,System.Threading.CancellationToken)` | Creates a floating pane in this window. |
| `LibTmux.Window.CreateWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window next to this one. |
| `LibTmux.Window.DisplayMessageAsync(LibTmux.DisplayMessageRequest,System.Threading.CancellationToken)` | Shows a message on the client viewing this window. |
@@ -614,15 +593,6 @@ modes differ.
| `LibTmux.WindowId.Parse(System.String)` | Parses a prefixed window identifier. |
| `LibTmux.WindowId.ToString` | Returns the canonical prefixed identifier. |
| `LibTmux.WindowId.TryParse(System.String,LibTmux.WindowId@)` | Tries to parse a prefixed window identifier. |
-| `System.Text.RegularExpressions.Generated.Utilities.StackPop(System.Int32[],System.Int32@,System.Int32@,System.Int32@)` | Pops 2 values from the backtracking stack. |
-| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32)` | Pushes 1 value onto the backtracking stack. |
-| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32,System.Int32)` | Pushes 2 values onto the backtracking stack. |
-| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32,System.Int32,System.Int32)` | Pushes 3 values onto the backtracking stack. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.#ctor` | Initializes the instance. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.CreateInstance` | Creates an instance of a used by methods on . |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.Scan(System.ReadOnlySpan{System.Char})` | Scan the starting from base.runtextstart for the next match. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.TryFindNextPossibleStartingPosition(System.ReadOnlySpan{System.Char})` | Search starting from base.runtextpos for the next location a match could possibly start. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.TryMatchAtCurrentPosition(System.ReadOnlySpan{System.Char})` | Determine whether at base.runtextpos is a match for the regular expression. |
## Properties
@@ -655,9 +625,7 @@ modes differ.
| `LibTmux.CapturePaneRequest.Quiet` | Gets whether a missing alternate screen is not an error. |
| `LibTmux.CapturePaneRequest.StartLine` | Gets the first line to capture. |
| `LibTmux.CapturePaneRequest.TrimTrailingSpaces` | Gets whether trailing spaces are removed. |
-| `LibTmux.CapturedRelation`1.CapturedDepth` | Gets the depth the owning snapshot reached. |
-| `LibTmux.CapturedRelation`1.IsCaptured` | Gets whether the snapshot read this relation. |
-| `LibTmux.CapturedRelation`1.Relation` | Gets the relation name this instance carries. |
+| ``LibTmux.CapturedRelation`1.IsCaptured`` | Gets whether the snapshot read this relation. |
| `LibTmux.ChooseTreeRequest.Format` | Gets the format each row renders with. |
| `LibTmux.ChooseTreeRequest.NativeFilter` | Gets the raw tmux filter limiting the rows. |
| `LibTmux.ChooseTreeRequest.Reverse` | Gets whether the order is reversed. |
@@ -768,6 +736,7 @@ modes differ.
| `LibTmux.IfShellRequest.ThenCommand` | Gets the tmux command run when it succeeds. |
| `LibTmux.IncompleteSnapshotException.CapturedDepth` | Gets the depth the snapshot actually reached. |
| `LibTmux.IncompleteSnapshotException.Relation` | Gets the relation the caller asked for. |
+| `LibTmux.LibTmuxException.Dispatch` | Gets whether the command reached tmux, and so whether a retry is safe. |
| `LibTmux.LibTmuxInfo.MaximumTestedTmuxVersion` | Gets the highest required tested tmux version. |
| `LibTmux.LibTmuxInfo.MinimumTmuxVersion` | Gets the minimum supported tmux version. |
| `LibTmux.LibTmuxInfo.Version` | Gets the library assembly version. |
@@ -846,7 +815,6 @@ modes differ.
| `LibTmux.Pane.RawFormatFields` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Pane.Server` | Gets the server that owns this pane. |
| `LibTmux.Pane.Session` | Gets the session containing this pane. |
-| `LibTmux.Pane.Snapshot` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Pane.Title` | Gets the pane title captured with this handle. |
| `LibTmux.Pane.Width` | Gets the pane width captured with this handle. |
| `LibTmux.Pane.Window` | Gets the window containing this pane. |
@@ -861,6 +829,36 @@ modes differ.
| `LibTmux.PipePaneRequest.InputOnly` | Gets whether only pane input is piped. |
| `LibTmux.PipePaneRequest.OutputOnly` | Gets whether only pane output is piped. |
| `LibTmux.PipePaneRequest.Toggle` | Gets whether an identical existing pipe is stopped instead. |
+| `LibTmux.PsmuxCaptureOptions.EndLine` | Gets the last line to capture. |
+| `LibTmux.PsmuxCaptureOptions.EscapeSequences` | Gets whether terminal escape sequences are preserved. |
+| `LibTmux.PsmuxCaptureOptions.JoinWrappedLines` | Gets whether wrapped screen rows are joined. |
+| `LibTmux.PsmuxCaptureOptions.StartLine` | Gets the first line to capture. |
+| `LibTmux.PsmuxConnectionOptions.DataDirectory` | Gets the canonical isolated data-directory path on a fixed local Windows drive. |
+| `LibTmux.PsmuxConnectionOptions.ExecutablePath` | Gets the local absolute psmux client executable path. |
+| `LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256` | Gets the expected executable SHA-256 in lowercase hexadecimal. |
+| `LibTmux.PsmuxConnectionOptions.Logger` | Gets the optional connection logger. |
+| `LibTmux.PsmuxConnectionOptions.NamespaceName` | Gets the explicit non-default psmux namespace. |
+| `LibTmux.PsmuxPane.Height` | Gets the captured height in rows. |
+| `LibTmux.PsmuxPane.Id` | Gets the captured pane identifier. |
+| `LibTmux.PsmuxPane.Index` | Gets the captured pane index. |
+| `LibTmux.PsmuxPane.Server` | Gets the psmux endpoint that produced this observation. |
+| `LibTmux.PsmuxPane.SessionId` | Gets the captured parent session identifier. |
+| `LibTmux.PsmuxPane.Title` | Gets the captured pane title. |
+| `LibTmux.PsmuxPane.Width` | Gets the captured width in columns. |
+| `LibTmux.PsmuxPane.WindowId` | Gets the captured parent window identifier. |
+| `LibTmux.PsmuxServer.ConnectionOptions` | Gets the connection settings used for this observation. |
+| `LibTmux.PsmuxServer.Version` | Gets the psmux compatibility version reported at connection time. |
+| `LibTmux.PsmuxSession.Attached` | Gets whether a client was attached when the session was read. |
+| `LibTmux.PsmuxSession.Id` | Gets the captured session identifier. |
+| `LibTmux.PsmuxSession.Name` | Gets the captured session name. |
+| `LibTmux.PsmuxSession.Server` | Gets the psmux endpoint that produced this observation. |
+| `LibTmux.PsmuxWindow.Height` | Gets the captured height in rows. |
+| `LibTmux.PsmuxWindow.Id` | Gets the captured window identifier. |
+| `LibTmux.PsmuxWindow.Index` | Gets the captured window index. |
+| `LibTmux.PsmuxWindow.Name` | Gets the captured window name. |
+| `LibTmux.PsmuxWindow.Server` | Gets the psmux endpoint that produced this observation. |
+| `LibTmux.PsmuxWindow.SessionId` | Gets the captured parent session identifier. |
+| `LibTmux.PsmuxWindow.Width` | Gets the captured width in columns. |
| `LibTmux.Query.AndNode.Operands` | Gets the ordered operands. |
| `LibTmux.Query.BooleanConstant.Value` | The literal value. |
| `LibTmux.Query.ComparisonNode.Left` | The left operand. |
@@ -963,13 +961,6 @@ modes differ.
| `LibTmux.ServerConnectionOptions.TmuxBinaryPath` | Gets the tmux executable path. |
| `LibTmux.ServerGeneration.ProcessId` | Gets the tmux daemon process identifier. |
| `LibTmux.ServerGeneration.StartTime` | Gets the tmux daemon start time. |
-| `LibTmux.ServerSnapshot.Depth` | Gets how far down the hierarchy the capture reached. |
-| `LibTmux.ServerSnapshot.Generation` | Gets the generation observed during capture. |
-| `LibTmux.ServerSnapshot.Panes` | Gets the captured panes, across every window. |
-| `LibTmux.ServerSnapshot.Server` | Gets the server this snapshot was read from. |
-| `LibTmux.ServerSnapshot.Sessions` | Gets the captured sessions. |
-| `LibTmux.ServerSnapshot.WindowEdges` | Gets every session-to-window edge the capture observed. |
-| `LibTmux.ServerSnapshot.Windows` | Gets the captured windows, once per session they are linked into. |
| `LibTmux.Session.ActivePane` | Gets the active pane recorded when this session was read. |
| `LibTmux.Session.ActiveWindow` | Gets the active window recorded when this session was read. |
| `LibTmux.Session.Attached` | Gets whether a client was attached when this session was read. |
@@ -982,7 +973,6 @@ modes differ.
| `LibTmux.Session.Panes` | Gets the panes the capture found in this session. |
| `LibTmux.Session.RawFormatFields` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Session.Server` | Gets the server that owns this session. |
-| `LibTmux.Session.Snapshot` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Session.Windows` | Gets the windows the capture found in this session. |
| `LibTmux.SessionId.Value` | Gets the nonnegative numeric value. |
| `LibTmux.SessionWindowEdge.Key` | Gets the session and window this edge joins. |
@@ -1010,8 +1000,6 @@ modes differ.
| `LibTmux.SetOptionRequest.Quiet` | Gets whether a rejected option is answered with nothing instead of an error. |
| `LibTmux.SetOptionRequest.Scope` | Gets the scope to set in, or null for the owner's own. |
| `LibTmux.SetOptionRequest.Value` | Gets the value to store. |
-| `LibTmux.SnapshotLookup`2.Count` | Gets the number of indexed elements. |
-| `LibTmux.SnapshotLookup`2.Item(`0)` | Gets the element with the given key. |
| `LibTmux.SplitPaneRequest.ActiveBorderStyle` | Gets the border style while the pane is active. |
| `LibTmux.SplitPaneRequest.Attach` | Gets whether the new pane becomes active. |
| `LibTmux.SplitPaneRequest.Command` | Gets the command the new pane runs. |
@@ -1070,6 +1058,8 @@ modes differ.
| `LibTmux.TmuxEnvironmentEntry.IsRemoved` | Gets whether tmux strips this variable from new panes. |
| `LibTmux.TmuxEnvironmentEntry.Name` | Gets the variable name. |
| `LibTmux.TmuxEnvironmentEntry.Value` | Gets the value, or null when the variable is marked removed. |
+| `LibTmux.TmuxEventsDroppedEvent.Count` | The events discarded since the previous loss report. |
+| `LibTmux.TmuxEventsDroppedEvent.TotalDropped` | The events discarded over this control client's lifetime. |
| `LibTmux.TmuxExitEvent.Reason` | Why tmux said it ended, when it said anything. It is silent for an ordinary exit and names a reason when the server went away underneath the client. |
| `LibTmux.TmuxHook.Name` | Gets the hook name, without an index. |
| `LibTmux.TmuxHook.Values` | Gets the commands it runs, in the order tmux reported. |
@@ -1135,7 +1125,6 @@ modes differ.
| `LibTmux.Window.RawFormatFields` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Window.Server` | Gets the server that owns this window. |
| `LibTmux.Window.Session` | Gets the session this window was read through. |
-| `LibTmux.Window.Snapshot` | Gets the tmux fields captured when this handle materialized. |
| `LibTmux.Window.Width` | Gets the window width captured with this handle. |
| `LibTmux.WindowEntityKey.SessionId` | The session the window is linked into. |
| `LibTmux.WindowEntityKey.WindowId` | The linked window. |
@@ -1172,14 +1161,15 @@ modes differ.
| `LibTmux.PromptType.Search` | Text to search for. |
| `LibTmux.PromptType.Target` | A target to act on. |
| `LibTmux.PromptType.WindowTarget` | A window to act on. |
+| `LibTmux.PsmuxServer.SupportedBinarySha256` | Gets the exact psmux client executable SHA-256 accepted by this preview. |
+| `LibTmux.PsmuxServer.SupportedCommit` | Gets the exact psmux source commit accepted by this preview. |
+| `LibTmux.PsmuxServer.SupportedImplementationBanner` | Gets the exact clean implementation banner accepted by this preview. |
| `LibTmux.Query.QueryComparison.Equal` | Operands are equal. |
| `LibTmux.Query.QueryComparison.GreaterThan` | The left operand is larger. |
| `LibTmux.Query.QueryComparison.GreaterThanOrEqual` | The left operand is not smaller. |
| `LibTmux.Query.QueryComparison.LessThan` | The left operand is smaller. |
| `LibTmux.Query.QueryComparison.LessThanOrEqual` | The left operand is not larger. |
| `LibTmux.Query.QueryComparison.NotEqual` | Operands differ. |
-| `LibTmux.Query.QueryDocument.CurrentSchema` | The current wire schema identifier. |
-| `LibTmux.Query.QueryDocument.CurrentVersion` | The current wire schema version. |
| `LibTmux.Query.QueryQuantifier.All` | True when every child matches; true when empty. |
| `LibTmux.Query.QueryQuantifier.Any` | True when at least one child matches; false when empty. |
| `LibTmux.Query.QueryStringOperation.ContainsOrdinal` | Ordinal substring match. |
@@ -1208,6 +1198,9 @@ modes differ.
| `LibTmux.TmuxColorMode.Colors256` | Requests 256-color mode. |
| `LibTmux.TmuxColorMode.Default` | Uses tmux's default color behavior. |
| `LibTmux.TmuxColorMode.TrueColor` | Requests RGB true-color mode. |
+| `LibTmux.TmuxDispatchState.Dispatched` | tmux ran the command and answered. The failure is tmux refusing or reporting an error, not the command going missing, so any side effect it had before failing has already happened. |
+| `LibTmux.TmuxDispatchState.NotDispatched` | The command never reached tmux, so nothing was done and a retry repeats nothing. This is the only state in which retrying is unconditionally safe. |
+| `LibTmux.TmuxDispatchState.Unknown` | Whether tmux acted on the command cannot be determined. Treat a retry as capable of repeating whatever the command does. |
| `LibTmux.TmuxOptionState.Absent` | tmux named the option but gave it no value. |
| `LibTmux.TmuxOptionState.Off` | tmux reported the flag value off. |
| `LibTmux.TmuxOptionState.On` | tmux reported the flag value on. |
@@ -1222,6 +1215,3 @@ modes differ.
| `LibTmux.WindowResizeMode.Shrink` | Size the window to its smallest client. |
| `LibTmux.WindowRotationDirection.Down` | Rotate panes towards the bottom of the window. |
| `LibTmux.WindowRotationDirection.Up` | Rotate panes towards the top of the window. |
-| `System.Text.RegularExpressions.Generated.Utilities.s_defaultTimeout` | Default timeout value set in , or if none was set. |
-| `System.Text.RegularExpressions.Generated.Utilities.s_hasTimeout` | Whether is non-infinite. |
-| `System.Text.RegularExpressions.Generated.VersionRegex_0.Instance` | Cached, thread-safe singleton instance. |
diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md
index 7133e62..20323a1 100644
--- a/docs/decisions/0004-public-api-approval.md
+++ b/docs/decisions/0004-public-api-approval.md
@@ -58,10 +58,11 @@ Raw execution returns an immutable `TmuxCommandResult` with logical arguments,
exit code, raw standard-output and standard-error bytes, and normalized line
views. Nonzero tmux exit is inspectable data at the raw boundary.
-Process-backed entry points carry
-`[UnsupportedOSPlatform("windows")]` and perform a runtime platform guard.
-Portable IDs, snapshots, query translation and interpretation, and JSON remain
-available on Windows.
+Process-backed entry points carry `[UnsupportedOSPlatform("windows")]`.
+The separate `Psmux*` query facade is analyzer-clean because it exposes only
+the pinned-client, bounded preview; it does not make the ordinary tmux entity
+surface Windows-compatible. Portable IDs, snapshots, query translation and
+interpretation, and JSON remain available on Windows.
### Hierarchy, identity, and ownership
diff --git a/docs/mcp/README.md b/docs/mcp/README.md
new file mode 100644
index 0000000..ff322fd
--- /dev/null
+++ b/docs/mcp/README.md
@@ -0,0 +1,234 @@
+# tmux MCP server
+
+How the server behaves: what to wait on, what bounds a result, which tools a
+tier registers, and what a subscription holds open.
+
+[The tool reference](tools.md) is generated by asking the server what it
+advertises, so it cannot describe a surface that is not there. [The package
+README](../../src/LibTmux.Mcp/README.md) covers installing the server, pointing
+a client at it, and the environment variables that configure it.
+
+## Waiting, not polling
+
+The tool an assistant reaches for first is usually the wrong one. These four
+cover the cases, and the server's instructions steer between them:
+
+| You want | Use | Why |
+|---|---|---|
+| Run a command, know if it worked | `tmux_run` | Waits, returns the shell's **real exit status** |
+| The same, but it takes minutes | `tmux_start_job` → `tmux_job` | Returns a handle at once; collect later |
+| Output you did **not** start | `tmux_wait_for_text` | Normally wakes from pane output; bounded polling is the fallback |
+| Watch a pane across turns | `tmux_tail_pane` | Answers only what is **new** since its cursor |
+
+A job handle stays bound to the exact socket, tmux process, and pane that
+started it. Collection and cancellation use that recorded endpoint; command
+text is not retained or returned because shell commands often contain secrets.
+Collection advances only after its complete MCP response fits the byte ceiling,
+so retrying a rejected call does not lose output. A cancelled job retains its
+capacity slot until the tmux watcher ends. Starting refuses before sending the
+command if its durable handle cannot fit the response ceiling.
+
+A client that speaks the [Tasks extension](https://modelcontextprotocol.io) can
+start `tmux_wait_for_text`, `tmux_wait_for_channel` or `tmux_job` as a task and
+collect the result later. It is offered, never required, so a client without it
+keeps the blocking call it had. Use `tmux_start_job` for a command that must be
+recoverable across calls: `tmux_run` stays synchronous because an SDK task has
+no durable tmux job handle if its client disconnects. Listings also stay plain
+calls; making one a task would add a round trip to an answer already available.
+The in-memory task store admits at most 8 active executions and retains at most
+256 results for 15 minutes. Cancellation keeps its active slot until the
+background execution actually stops.
+
+Normally a wait does not sleep in a loop. It subscribes to tmux's own
+[control mode](https://github.com/tmux/tmux/wiki/Control-Mode), so tmux reports
+pane output as it happens and the wait is released the moment there is
+something to look at.
+
+Two details make that safe. The control client attaches with `ignore-size`
+(tmux 3.2+), so it never drags the window down to its own size; and it is
+reference counted per session, so it exists only while a wait is running. What
+arrives on that stream is the pane's raw terminal bytes, so it is used as a
+signal and never as content — the text you get always comes from a capture,
+which is what tmux has already rendered.
+
+If control mode cannot start, waits fall back to polling. Cost changes;
+answers do not.
+
+The tools are ordinary classes, so an application that already hosts an
+assistant can run one directly instead of launching a second process:
+
+
+```csharp
+using LibTmux;
+using LibTmux.Mcp;
+
+WriteTools tools = McpTools.Writing(server);
+
+RunResult result = await tools.RunAsync(
+ "test -f /etc/hostname && echo present",
+ pane.Id.ToString(),
+ timeoutSeconds: 20,
+ cancellationToken: ct);
+
+// The status comes from the shell, not from reading the screen, so a
+// command that prints nothing still says what it did.
+Console.WriteLine($"exit {result.ExitStatus}, timed out: {result.TimedOut}");
+```
+
+
+If `TimedOut` is true, the shell command may still be running. Inspect the pane
+and do not call `tmux_run` again; use `tmux_start_job` for work that needs a
+durable handle beyond one wait.
+
+## Nothing returns unbounded output
+
+Every serialized tool result has a hard byte ceiling. Terminal-text results
+keep the **newest** lines and say what they dropped:
+
+```json
+{
+ "lines": ["...", "make: *** [build] Error 1"],
+ "truncated": true,
+ "droppedLines": 407,
+ "droppedBytes": 2034
+}
+```
+
+A reader that cannot see lines are missing concludes the pane never printed
+them:
+
+
+```csharp
+using LibTmux;
+using LibTmux.Mcp;
+
+ReadTools reading = McpTools.Reading(server);
+
+CaptureResult captured = await reading.CapturePaneAsync(
+ pane.Id.ToString(),
+ includeHistory: true,
+ maxLines: 5,
+ cancellationToken: ct);
+
+// The newest line says what happened, so the budget keeps the end and
+// reports what was dropped — silence would look like nothing printed.
+Console.WriteLine(captured.Content.ToDisplayString());
+Console.WriteLine($"dropped {captured.Content.DroppedLines} earlier lines");
+```
+
+
+`tmux_tail_pane` avoids the problem instead of managing it. Pass its cursor
+back and the tenth read of a busy pane costs what the first did:
+
+
+```csharp
+using LibTmux;
+using LibTmux.Mcp;
+
+ReadTools reading = McpTools.Reading(server);
+string paneId = pane.Id.ToString();
+
+// A first call establishes a position and returns nothing, so watching
+// a pane never starts by paying for a screenful nobody asked for.
+TailResult first = await reading.TailPaneAsync(paneId, cancellationToken: ct);
+
+await reading.WaitForTextAsync(
+ paneId,
+ patterns: null,
+ timeoutSeconds: 5,
+ cancellationToken: ct);
+
+TailResult next = await reading.TailPaneAsync(paneId, first.Cursor, cancellationToken: ct);
+Console.WriteLine($"{next.Content.Lines.Count} new lines");
+```
+
+
+A cursor is opaque, authenticated, and bound to the exact socket, tmux server
+generation, and pane that issued it. It cannot be moved between panes or
+servers, and restarting the MCP server expires it; omit an expired cursor to
+establish a new position.
+
+The byte ceiling covers serialized tool and resource results, including text,
+structured content, and metadata. Content tools truncate within it and report
+the loss; a result that still cannot fit is replaced by a small error that says
+how to narrow the call or raise the ceiling. Oversized resource reads fail with
+the same guidance. `tmux_search_panes` also caps its pattern at 4096 UTF-8 bytes
+before compiling it or contacting tmux. Pane waits accept at most 32 patterns,
+4096 bytes each and 16384 bytes together; channel names share the 4096-byte
+input bound.
+
+To offer these beside your own tools rather than as a separate process:
+
+
+```csharp
+using LibTmux;
+using LibTmux.Mcp;
+using Microsoft.Extensions.DependencyInjection;
+
+ServiceCollection services = new();
+services.AddLogging();
+
+// Registers the tools, resources and prompts, and gates them on the
+// tier. Choose the transport yourself — this returns the builder.
+McpServerComposition.Add(
+ services,
+ new ServerPolicy { Tier = SafetyTier.ReadOnly },
+ server.ConnectionOptions,
+ callerPaneId: null);
+```
+
+
+## Three tiers, and a tool you do not have cannot be called
+
+`LIBTMUX_SAFETY` picks how much of tmux is exposed. Tools above the tier are
+**not registered**, so they never reach the model's list:
+
+| `LIBTMUX_SAFETY` | Offers | Example |
+|---|---|---|
+| `readonly` | Reading only | `tmux_capture_pane`, `tmux_search_panes` |
+| `mutating` *(default)* | Reading, creating, changing | `tmux_run`, `tmux_split_pane` |
+| `destructive` | Everything, including removal | `tmux_kill_session` |
+
+A tier bounds the tools, not the intent: an assistant denied `tmux_kill_session`
+can still type `exit` into a pane with `tmux_send_keys`. Use `readonly` when
+that distinction matters.
+
+## Resources and prompts
+
+Four fixed resources expose the hierarchy without a tool call:
+`tmux://hierarchy`, `tmux://sessions`, `tmux://self`, and `tmux://servers`.
+Two resource templates address `tmux://sessions/{id}/panes` and
+`tmux://panes/{id}/content`. A client can pin or refresh one on its own
+initiative; one nobody reads costs nothing.
+
+A client that subscribes to `tmux://hierarchy`, `tmux://sessions` or
+`tmux://servers` is told when they change, from tmux's own notifications rather
+than from a timer — so a view goes stale only when something actually moved.
+That watcher holds a second control client, started on the first subscription
+and stopped with the last, attached with `no-output` because it wants the
+hierarchy and not every byte a pane prints.
+
+Both subscription shapes are served: `resources/subscribe`, and the
+`subscriptions/listen` stream that replaced it in the 2026-07-28 revision.
+Duplicate resource URIs are one subscription, and one server admits at most
+eight concurrent listen streams; cancel an existing stream before opening a
+ninth. Legacy duplicate subscribe requests are likewise one subscription and
+one unsubscribe removes it.
+The newer one is answered by this server rather than by the SDK's built-in
+handling, because that grants the subscription without telling the application
+— which would leave a client subscribed to a watcher nobody started, waiting
+for events that never come.
+
+Long calls report progress while they run, so a wait shows as running rather
+than hung. It costs nothing when the client asks for none.
+
+Four prompts package workflows that are easy to get wrong:
+`tmux_run_and_report`, `tmux_diagnose_pane`, `tmux_build_workspace`,
+`tmux_interrupt_pane`.
+
+## Standard output belongs to the protocol
+
+Every log line goes to standard error, and the default level is `Warning` so a
+working server is quiet. A message written to the wrong stream does not produce
+a stray log line — it corrupts the protocol and the client disconnects. That is
+worth knowing if you wrap this in something of your own.
diff --git a/docs/modes/control-mode.md b/docs/modes/control-mode.md
index 7ec2ff8..9e11244 100644
--- a/docs/modes/control-mode.md
+++ b/docs/modes/control-mode.md
@@ -45,6 +45,40 @@ and the stream looks mysteriously quiet.
The stream ends with `TmuxExitEvent` and then completes, so an `await foreach`
is released rather than hanging when the server goes away.
+Notifications use a bounded, non-blocking buffer so a slow observer cannot
+stall command replies or the control reader. If the buffer fills, the oldest
+events are discarded and a `TmuxEventsDroppedEvent` appears immediately before
+the next retained event. `Count` is the loss since the previous marker and
+`TotalDropped` is the lifetime total. Treat the marker as cache invalidation:
+re-read any state that depends on notifications. Command replies travel through
+a separate queue and are not dropped by this buffer.
+
+The marker arrives in sequence, where the discarded events would have been:
+
+
+```csharp
+await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
+
+await control.SendAsync("new-window -d -n build", ct);
+
+await foreach (TmuxEvent observed in control.Events.WithCancellation(ct))
+{
+ if (observed is TmuxEventsDroppedEvent dropped)
+ {
+ // Anything cached from this stream is now a guess, so the
+ // marker is a signal to re-read rather than to log.
+ Console.WriteLine($"missed {dropped.Count}, {dropped.TotalDropped} in total");
+ continue;
+ }
+
+ if (observed is TmuxNotificationEvent { Name: "window-add" })
+ {
+ break;
+ }
+}
+```
+
+
`SendAsync` is safe to call concurrently: tmux answers in the order it was
asked, and each caller gets its own answer.
diff --git a/docs/psmux.md b/docs/psmux.md
new file mode 100644
index 0000000..b86f28b
--- /dev/null
+++ b/docs/psmux.md
@@ -0,0 +1,277 @@
+# psmux query preview on Windows
+
+LibTmux contains an experimental facade for querying one isolated
+[psmux](https://github.com/psmux/psmux) namespace from native Windows .NET or
+from a WSL .NET process launching a Windows executable. The release workflow
+requires both paths to pass on net8.0 and net10.0; this is a deliberately narrow
+preview, not general Windows or tmux parity.
+
+The public preview is a separate, analyzer-clean API. It exposes only the
+behavior audited for the pinned psmux build:
+
+| Surface | Preview state |
+| --- | --- |
+| Connect and read the sole session | `PsmuxServer`, `PsmuxSession` |
+| Enumerate windows and panes | `PsmuxWindow`, `PsmuxPane` |
+| Capture pane text | `PsmuxPane.CaptureAsync(PsmuxCaptureOptions)` |
+| Accepted client artifact | One exact Windows x64 executable; release requires a published URL with pinned source and license provenance |
+| Native Windows .NET | Required by the release harness on net8.0 and net10.0 |
+| WSL-to-Windows process interop | Required by the release harness on net8.0 and net10.0 |
+| Session/server lifecycle or mutations | Rejected |
+| Raw commands, chaining, control mode, waits, streaming, MCP | Rejected or unavailable |
+| Multiple sessions, default namespace, socket paths | Rejected |
+
+The ordinary `Server`, `Session`, `Window`, and `Pane` surface keeps its Windows
+unsupported annotations because its lifecycle, mutation, grouping, control-mode,
+and atomic stale-handle contracts still require real tmux. Preview callers do
+not suppress `CA1416`; they use the `Psmux*` types instead.
+
+## Pinned build and trust boundary
+
+The only accepted client is one Windows x64 executable built from clean psmux
+source at commit
+`aa26cd39edcfab03e718f94ea21bb47e8c5b85e8`, with the exact banner
+`psmux 3.3.7 (aa26cd3 2026-08-17)` and executable SHA-256
+`1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e`.
+`PsmuxConnectionOptions` requires:
+
+- an absolute `.exe` path on a fixed local Windows drive rather than `PATH` or
+ a network share;
+- that exact SHA-256, also exposed as `PsmuxServer.SupportedBinarySha256`;
+- an absolute local-drive `PSMUX_DATA_DIR` dedicated to this integration; and
+- an explicit, non-default, 16–64 character namespace containing one session.
+
+The namespace uses lowercase ASCII letters, digits, `-`, and `_`; `__` is
+rejected. It should be high entropy because psmux discovers `-L name`
+registries with a prefix scan. Session names may also use uppercase ASCII.
+The data-directory drive letter is uppercased and its segments lowercased
+before endpoint identity is constructed. Native Windows rejects mapped,
+removable, and network drives. WSL cannot query the Windows drive type, so WSL
+callers must ensure both paths are backed by a fixed local Windows drive.
+Network shares, filesystem roots, and reserved Windows segments are rejected
+because psmux treats this directory as machine-local registry and
+process-ownership state. Case-sensitive Windows directories are outside this
+preview.
+
+LibTmux hashes the client and checks its audited build markers before every
+launch, then requires the exact two-line version banner. Those checks attest
+the client file only. They do not attest the already-running server executable,
+its configuration, or replacement of the path between verification and
+`Process.Start`. Provision the session with the same verified clean binary at a
+caller-controlled, immutable, non-symlink path and an alias-free configuration
+with warm helpers disabled.
+
+The psmux 3.3.7 release build at `05cc5d4` is unsafe for this integration. Its
+startup reaper can terminate psmux, tmux, or pmux listeners owned by another
+data directory or Windows profile, and that reaper runs before `-V` is parsed.
+Never point LibTmux or the smoke harness at that installed build; rejecting its
+banner would already be too late.
+
+### Artifact availability
+
+The accepted Windows x64 executable is a maintainer validation artifact, not a
+published psmux release asset. Commit `aa26cd3` is not tagged. At that commit,
+the upstream release workflow requires an existing tag but does not pin its
+checkout to the tag input. It also builds with the moving `windows-latest`
+image and `stable` Rust toolchain. Rebuilding the same source therefore does
+not promise the exact bytes required by this preview. A matching source banner
+is not a substitute for the pinned SHA-256.
+
+Publication of the exact accepted artifact, or selection and review of a
+published replacement with a new pinned hash, is a prerequisite to shipping
+this preview. Without that published artifact URL, the native/WSL smoke is
+available only to a tester who already has the exact artifact. Do not install
+or rebuild psmux and assume the result is accepted.
+
+What a release needs on top of this contract — the repository variables and
+the self-hosted runner — is in
+[`CONTRIBUTING.md`](../.github/CONTRIBUTING.md#the-psmux-preview-gates).
+
+## Query from C#
+
+The example below is compiled as part of the examples project and published
+from that source. It takes all endpoint trust values explicitly and cannot
+reach mutations, lifecycle, raw commands, chains, or control mode.
+
+
+```csharp
+using LibTmux;
+
+using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+CancellationToken cancellationToken = cancellation.Token;
+
+string executable = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY")
+ ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required.");
+string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR")
+ ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required.");
+string namespaceName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE")
+ ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required.");
+
+PsmuxServer server = await PsmuxServer.ConnectAsync(
+ new PsmuxConnectionOptions(
+ executablePath: executable,
+ expectedBinarySha256: PsmuxServer.SupportedBinarySha256,
+ dataDirectory: dataDirectory,
+ namespaceName: namespaceName),
+ cancellationToken);
+PsmuxSession session = await server.GetSessionAsync(cancellationToken);
+
+Console.WriteLine($"{session.Id} {session.Name}");
+foreach (PsmuxWindow window in await session.GetWindowsAsync(cancellationToken))
+{
+ Console.WriteLine($" {window.Id} {window.Index}: {window.Name}");
+ foreach (PsmuxPane pane in await window.GetPanesAsync(cancellationToken))
+ {
+ IReadOnlyList lines = await pane.CaptureAsync(
+ new PsmuxCaptureOptions(joinWrappedLines: true),
+ cancellationToken);
+ Console.WriteLine($" {pane.Id} {pane.Width}x{pane.Height}");
+ foreach (string line in lines)
+ {
+ Console.WriteLine($" {line}");
+ }
+ }
+}
+```
+
+
+`PsmuxSession`, `PsmuxWindow`, and `PsmuxPane` are immutable observations, not
+tmux-style stable handles. Call the query methods again for a fresh observation.
+Cancellation covers the streamed prelaunch hash and every client process; the
+facade introduces no synchronous file read or process wait.
+
+Each `PsmuxServer` is bound to the session generation seen at connection time.
+A later query throws `InvalidOperationException` if no live session remains and
+`StaleServerGenerationException` if the sole session was replaced; the latter
+derives from the former. Call `RefreshAsync` to obtain a replacement server
+observation. More than one visible session throws `NotSupportedException`, and
+a vanished window or pane can throw `TmuxObjectNotFoundException` during target
+preflight.
+
+## Native Windows and WSL smoke
+
+Build the complete solution from WSL first. This produces both target
+frameworks for every packable project and for the checked-in example:
+
+```console
+$ mise exec -- dotnet build \
+ LibTmux.slnx \
+ --configuration Release \
+ --warnaserror
+```
+
+Pack the final tree, then restore and build the downstream package consumer
+through a newly allocated package cache. A fresh cache prevents an older
+package with the same prerelease version from satisfying the test:
+
+```console
+$ mise exec -- dotnet pack \
+ LibTmux.slnx \
+ --configuration Release \
+ --no-build \
+ --output artifacts/packages
+```
+
+```console
+$ package_cache="$(mktemp -d /tmp/libtmux-dotnet-psmux-nuget.XXXXXX)" && \
+ NUGET_PACKAGES="$package_cache" mise exec -- dotnet restore \
+ LibTmux.slnx \
+ --locked-mode && \
+ NUGET_PACKAGES="$package_cache" mise exec -- dotnet restore \
+ tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj && \
+ NUGET_PACKAGES="$package_cache" mise exec -- dotnet build \
+ tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \
+ --configuration Release \
+ --framework net10.0 \
+ --no-restore \
+ --warnaserror
+```
+
+Then run the checked-in harness from native Windows PowerShell. Replace the
+example paths with paths on the test machine, but retain the exact SHA shown
+below: the harness rejects every other artifact. `DataDirectory` must be a
+fresh, nonexistent, high-entropy directory for each run; the harness refuses an
+existing directory rather than overwriting anything in it. It disables psmux's
+warm helper, removes only the exact session identity it created, and deletes the
+owned directory only after the server process and every live registry entry are
+gone. The optional WSL arguments make the same native PowerShell process keep
+the server alive while both native .NET and WSL .NET query it:
+
+```console
+$ & '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\eng\psmux\Invoke-PsmuxSmoke.ps1' `
+ -PsmuxPath 'C:\Tools\psmux-aa26cd3\psmux.exe' `
+ -ExpectedSha256 '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e' `
+ -DataDirectory 'C:\Users\me\AppData\Local\Temp\libtmux-psmux-smoke-01a00fd3' `
+ -NamespaceName 'libtmux_smoke_01a00fd3' `
+ -DotnetPath 'C:\Program Files\dotnet\dotnet.exe' `
+ -TestAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\tests\LibTmux.UnitTests\bin\Release\net10.0\LibTmux.UnitTests.dll' `
+ -ExampleAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\examples\LibTmux.Examples\bin\Release\net10.0\LibTmux.Examples.dll' `
+ -PackageConsumerAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\tests\LibTmux.PackageConsumer\bin\Release\net10.0\LibTmux.PackageConsumer.dll' `
+ -TargetFramework 'net10.0' `
+ -RunWslSmoke `
+ -WslDistribution 'Ubuntu-24.04' `
+ -WslRepository '/home/d/work/libtmux/libtmux-dotnet' `
+ -WslDotnetPath '/home/d/.config/mise/dotnet-root/dotnet'
+```
+
+Before its first psmux launch, the harness verifies the SHA and embedded audited
+build markers, creates the isolated directory, and sets `PSMUX_DATA_DIR`. It
+then requires the exact clean banner, creates an alias-free configuration with
+warm helpers disabled, starts
+exactly one `powershell.exe` session, writes `héllo-雪-😀`, and waits for that
+text to be capturable. Each native and optional WSL leg must pass the focused
+public-facade test, run the checked-in example, and query through the packed
+NuGet consumer. Repeat the build and harness with `net8.0` paths to cover both
+target frameworks. Finally, the harness re-resolves the session ID and
+generation it recorded after creation, refuses to kill a changed or unknown
+identity, removes only its fresh owned directory, and restores the process
+environment and console encoding. It never calls `kill-server` or touches the
+default namespace. A failing cleanup is a failing harness run.
+
+The WSL leg uses the `/mnt/c/...` executable path while retaining a
+Windows-absolute `PSMUX_DATA_DIR`. LibTmux owns and canonicalizes `WSLENV`,
+removes inherited tmux routing and every `PSMUX_*` entry case-insensitively,
+and forwards only `PSMUX_DATA_DIR/w` without path translation. WSL is a client
+in this workflow; native PowerShell owns the psmux server lifecycle. A bounded
+translation accepts either a Linux or Windows-absolute `WslRepository`, and a
+bounded preflight canonicalizes `WslDotnetPath` inside the selected
+distribution, requires the result to be an executable regular file, and
+confirms that it supplies the `Microsoft.NETCore.App` runtime matching the
+selected target framework. Every WSL leg then invokes that exact path; none
+depends on a login profile or the non-login `wsl.exe --exec` search path.
+
+## Exact compatibility limits
+
+- Exactly one pre-existing session must be visible. psmux window and pane IDs
+ repeat between session processes, and pid/start generation is per session.
+- The client-side allowlist admits only the precise list/display/capture command
+ shapes emitted by the typed facade. It rejects empty arguments, NUL, CR, LF,
+ quotes, unsafe backslashes, every semicolon, multiple target options, compact
+ or unsupported flags, `#(` shell formats, recursive `#{E...}`/`#{T...}`
+ formats, noncanonical capture ranges, and relative or symbolic targets.
+- This allowlist prevents accidental unsupported use; it is not a security
+ boundary. psmux expands configured canonical command aliases server-side,
+ and every client invocation performs owned registry maintenance. A trusted,
+ alias-free server configuration is required.
+- Registry enumeration can omit entries after timeout or authentication
+ failures. LibTmux exact-targets and validates each visible row, but cannot
+ prove enumeration completeness or eliminate namespace-prefix collisions.
+- Session, generation, and object checks are separate client processes. An
+ external process can mutate the namespace between a preflight and query.
+ psmux may then return active-object data or an `ERROR:` line with exit code
+ zero. Target and result parity under external mutation is not claimed.
+- Grouped commands and chains are rejected because psmux can silently execute
+ only the first argv-level command. Control mode is rejected because its `-C`
+ readiness framing is not tmux-compatible.
+- Socket paths, default namespaces, forced color modes, per-client config
+ files, session creation, lifecycle, mutations, environment discovery, and
+ raw commands are absent from the public preview.
+- `Server.FromEnvironment()` rejects psmux markers and fake psmux `TMUX` paths;
+ it never falls back to an installed `psmux.exe` or fake tmux `-S` routing.
+- Numeric version `3.3.7` has no tmux capability profile. Optional tmux flags
+ remain disabled rather than being inferred from a nearby release.
+
+This is a core one-shot query preview. `LibTmux.Workspace` and the creation
+helpers in `LibTmux.Testing` are unavailable. `LibTmux.Mcp` is also unavailable
+for psmux because its writes and waits depend on mutation and control mode.
+`LibTmux.Query.Json` remains portable but does not expand this command surface.
diff --git a/docs/public-api.json b/docs/public-api.json
index 3e52ed3..6305d9c 100644
--- a/docs/public-api.json
+++ b/docs/public-api.json
@@ -1170,6 +1170,102 @@
"state": [],
"summary": "Defines PromptType values."
},
+ {
+ "id": "T:LibTmux.PsmuxCaptureOptions",
+ "namespace": "LibTmux",
+ "name": "PsmuxCaptureOptions",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "Typed capture choices audited for the psmux query preview."
+ },
+ {
+ "id": "T:LibTmux.PsmuxConnectionOptions",
+ "namespace": "LibTmux",
+ "name": "PsmuxConnectionOptions",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "A pinned psmux client file and one isolated namespace."
+ },
+ {
+ "id": "T:LibTmux.PsmuxPane",
+ "namespace": "LibTmux",
+ "name": "PsmuxPane",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "An immutable pane observation from the psmux query preview."
+ },
+ {
+ "id": "T:LibTmux.PsmuxServer",
+ "namespace": "LibTmux",
+ "name": "PsmuxServer",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "reference",
+ "state": [],
+ "summary": "A query-only connection to one isolated psmux namespace."
+ },
+ {
+ "id": "T:LibTmux.PsmuxSession",
+ "namespace": "LibTmux",
+ "name": "PsmuxSession",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "An immutable observation of the sole psmux session."
+ },
+ {
+ "id": "T:LibTmux.PsmuxWindow",
+ "namespace": "LibTmux",
+ "name": "PsmuxWindow",
+ "kind": "class",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "object",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "An immutable window observation from the psmux query preview."
+ },
{
"id": "T:LibTmux.Query.AndNode",
"namespace": "LibTmux.Query",
@@ -2595,9 +2691,10 @@
},
"versionContract": {
"grammar": [
- "version = next / release / prerelease",
+ "version = next / release / micro / prerelease",
"next = \"next-\" core",
"release = core [patch] [\"-openbsd\"]",
+ "micro = core \".\" uint",
"prerelease = core (\"-rc\" posint / \"-dev\" [\".\" uint])",
"core = uint \".\" uint",
"patch = 1*LOWER",
@@ -2609,6 +2706,7 @@
"majorMinor": "the two invariant-culture decimal core components",
"suffixExamples": {
"3.7": null,
+ "3.3.7": "7",
"3.7b": "b",
"3.0-rc3": "rc3",
"3.3a-openbsd": "a-openbsd",
@@ -2632,7 +2730,7 @@
"03.7",
"3.07",
"3.7B",
- "3.7.1",
+ "3.7.01",
"3.7-",
"+3.7",
"integer component overflow"
@@ -2640,15 +2738,17 @@
},
"ordering": {
"core": "major then minor, numerically ascending",
- "sameCore": "next < dev < rcN < final < letter patch",
+ "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch",
"development": "a missing dev number precedes numeric dev numbers",
"releaseCandidate": "N compares numerically",
+ "micro": "N compares numerically",
"patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27",
"vendor": "-openbsd immediately follows its corresponding final or patch release",
"exactIdentity": "CompareTo returns zero if and only if equality is true",
"examples": [
"next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2",
"3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b",
+ "3.3 < 3.3.1 < 3.3.10 < 3.3a",
"3.7b < next-3.8 < 3.8"
],
"invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid",
@@ -2980,6 +3080,22 @@
"state": [],
"summary": "One thing a tmux control client reported without being asked."
},
+ {
+ "id": "T:LibTmux.TmuxEventsDroppedEvent",
+ "namespace": "LibTmux",
+ "name": "TmuxEventsDroppedEvent",
+ "kind": "record",
+ "package": "LibTmux",
+ "modifiers": [
+ "public",
+ "sealed"
+ ],
+ "baseType": "LibTmux.TmuxEvent",
+ "interfaces": [],
+ "ownership": "value",
+ "state": [],
+ "summary": "A loss marker emitted when the bounded control-event buffer overflows."
+ },
{
"id": "T:LibTmux.TmuxOutputEvent",
"namespace": "LibTmux",
@@ -3468,6 +3584,48 @@
"value": 3,
"static": true
},
+ {
+ "id": "F:LibTmux.PsmuxServer.SupportedBinarySha256",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "SupportedBinarySha256",
+ "kind": "field",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "const string LibTmux.PsmuxServer.SupportedBinarySha256",
+ "returnType": "string",
+ "portable": true,
+ "summary": "The exact psmux client executable SHA-256 accepted by this preview.",
+ "value": "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e",
+ "static": true
+ },
+ {
+ "id": "F:LibTmux.PsmuxServer.SupportedCommit",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "SupportedCommit",
+ "kind": "field",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "const string LibTmux.PsmuxServer.SupportedCommit",
+ "returnType": "string",
+ "portable": true,
+ "summary": "The exact psmux source commit accepted by this preview.",
+ "value": "aa26cd39edcfab03e718f94ea21bb47e8c5b85e8",
+ "static": true
+ },
+ {
+ "id": "F:LibTmux.PsmuxServer.SupportedImplementationBanner",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "SupportedImplementationBanner",
+ "kind": "field",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "const string LibTmux.PsmuxServer.SupportedImplementationBanner",
+ "returnType": "string",
+ "portable": true,
+ "summary": "The exact clean implementation banner accepted by this preview.",
+ "value": "psmux 3.3.7 (aa26cd3 2026-08-17)",
+ "static": true
+ },
{
"id": "F:LibTmux.Query.QueryComparison.Equal",
"declaringType": "T:LibTmux.Query.QueryComparison",
@@ -7868,150 +8026,446 @@
"summary": "Creates PipePaneRequest."
},
{
- "id": "M:LibTmux.Query.AndNode.#ctor(IReadOnlyList)",
- "declaringType": "T:LibTmux.Query.AndNode",
+ "id": "M:LibTmux.PsmuxCaptureOptions.#ctor(CapturePanePosition?,CapturePanePosition?,bool,bool)",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
"name": ".ctor",
"kind": "constructor",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "AndNode",
+ "returnType": "PsmuxCaptureOptions",
"parameters": [
{
- "name": "operands",
- "type": "IReadOnlyList"
- }
- ],
- "signature": "AndNode(IReadOnlyList operands)",
- "portable": true,
- "summary": "Creates AndNode."
- },
- {
- "id": "M:LibTmux.Query.BooleanConstant.#ctor(bool)",
- "declaringType": "T:LibTmux.Query.BooleanConstant",
- "name": ".ctor",
- "kind": "constructor",
- "visibility": "public",
- "package": "LibTmux",
- "static": false,
- "returnType": "BooleanConstant",
- "parameters": [
+ "name": "startLine",
+ "type": "CapturePanePosition?",
+ "default": "null"
+ },
{
- "name": "value",
- "type": "bool"
+ "name": "endLine",
+ "type": "CapturePanePosition?",
+ "default": "null"
+ },
+ {
+ "name": "escapeSequences",
+ "type": "bool",
+ "default": "false"
+ },
+ {
+ "name": "joinWrappedLines",
+ "type": "bool",
+ "default": "false"
}
],
- "signature": "BooleanConstant(bool value)",
+ "signature": "PsmuxCaptureOptions(CapturePanePosition? startLine = null, CapturePanePosition? endLine = null, bool escapeSequences = false, bool joinWrappedLines = false)",
"portable": true,
- "summary": "Creates BooleanConstant."
+ "summary": "Creates an audited psmux capture request."
},
{
- "id": "M:LibTmux.Query.ComparisonNode.#ctor(QueryComparison,QueryNode,QueryNode)",
- "declaringType": "T:LibTmux.Query.ComparisonNode",
+ "id": "M:LibTmux.PsmuxConnectionOptions.#ctor(string,string,string,string,ILogger?)",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
"name": ".ctor",
"kind": "constructor",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "ComparisonNode",
+ "returnType": "PsmuxConnectionOptions",
"parameters": [
{
- "name": "comparison",
- "type": "QueryComparison"
+ "name": "executablePath",
+ "type": "string"
},
{
- "name": "left",
- "type": "QueryNode"
+ "name": "expectedBinarySha256",
+ "type": "string"
},
{
- "name": "right",
- "type": "QueryNode"
+ "name": "dataDirectory",
+ "type": "string"
+ },
+ {
+ "name": "namespaceName",
+ "type": "string"
+ },
+ {
+ "name": "logger",
+ "type": "ILogger?",
+ "default": "null"
}
],
- "signature": "ComparisonNode(QueryComparison comparison, QueryNode left, QueryNode right)",
+ "signature": "PsmuxConnectionOptions(string executablePath, string expectedBinarySha256, string dataDirectory, string namespaceName, ILogger? logger = null)",
"portable": true,
- "summary": "Creates ComparisonNode."
+ "summary": "Creates one pinned client and isolated psmux endpoint."
},
{
- "id": "M:LibTmux.Query.ConstantNode.#ctor(QueryConstant)",
- "declaringType": "T:LibTmux.Query.ConstantNode",
- "name": ".ctor",
- "kind": "constructor",
+ "id": "M:LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions?,CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "CaptureAsync",
+ "kind": "method",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "ConstantNode",
+ "genericParameters": [],
+ "returnType": "Task>",
"parameters": [
{
- "name": "value",
- "type": "QueryConstant"
+ "name": "options",
+ "type": "PsmuxCaptureOptions?",
+ "default": "null"
+ },
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
}
],
- "signature": "ConstantNode(QueryConstant value)",
+ "signature": "Task> LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions? options = null, CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
"portable": true,
- "summary": "Creates ConstantNode."
+ "platformAnnotations": [],
+ "summary": "Captures pane text with best-effort target consistency."
},
{
- "id": "M:LibTmux.Query.EnumConstant.#ctor(string,string)",
- "declaringType": "T:LibTmux.Query.EnumConstant",
- "name": ".ctor",
- "kind": "constructor",
+ "id": "M:LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions,CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "ConnectAsync",
+ "kind": "method",
"visibility": "public",
"package": "LibTmux",
- "static": false,
- "returnType": "EnumConstant",
+ "static": true,
+ "genericParameters": [],
+ "returnType": "Task",
"parameters": [
{
- "name": "type",
- "type": "string"
+ "name": "options",
+ "type": "PsmuxConnectionOptions"
},
{
- "name": "value",
- "type": "string"
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
}
],
- "signature": "EnumConstant(string type, string value)",
+ "signature": "Task LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions options, CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
"portable": true,
- "summary": "Creates EnumConstant."
+ "platformAnnotations": [],
+ "summary": "Connects through the pinned client and validates one session."
},
{
- "id": "M:LibTmux.Query.FieldNode.#ctor(QueryTarget,string)",
- "declaringType": "T:LibTmux.Query.FieldNode",
- "name": ".ctor",
- "kind": "constructor",
+ "id": "M:LibTmux.PsmuxServer.GetPanesAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "GetPanesAsync",
+ "kind": "method",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "FieldNode",
+ "genericParameters": [],
+ "returnType": "Task>",
"parameters": [
{
- "name": "target",
- "type": "QueryTarget"
- },
- {
- "name": "wireName",
- "type": "string"
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
}
],
- "signature": "FieldNode(QueryTarget target, string wireName)",
+ "signature": "Task> LibTmux.PsmuxServer.GetPanesAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
"portable": true,
- "summary": "Creates FieldNode."
+ "platformAnnotations": [],
+ "summary": "Reads all current panes in the sole visible session."
},
{
- "id": "M:LibTmux.Query.InstantConstant.#ctor(long)",
- "declaringType": "T:LibTmux.Query.InstantConstant",
- "name": ".ctor",
- "kind": "constructor",
+ "id": "M:LibTmux.PsmuxServer.GetSessionAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "GetSessionAsync",
+ "kind": "method",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "InstantConstant",
+ "genericParameters": [],
+ "returnType": "Task",
"parameters": [
{
- "name": "unixSeconds",
- "type": "long"
- }
- ],
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task LibTmux.PsmuxServer.GetSessionAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reads the sole visible session or fails closed."
+ },
+ {
+ "id": "M:LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "GetWindowsAsync",
+ "kind": "method",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "genericParameters": [],
+ "returnType": "Task>",
+ "parameters": [
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task> LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reads all current windows in the sole visible session."
+ },
+ {
+ "id": "M:LibTmux.PsmuxServer.RefreshAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "RefreshAsync",
+ "kind": "method",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "genericParameters": [],
+ "returnType": "Task",
+ "parameters": [
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task LibTmux.PsmuxServer.RefreshAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reconnects and returns a fresh endpoint observation."
+ },
+ {
+ "id": "M:LibTmux.PsmuxSession.GetPanesAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "GetPanesAsync",
+ "kind": "method",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "genericParameters": [],
+ "returnType": "Task>",
+ "parameters": [
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task> LibTmux.PsmuxSession.GetPanesAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reads the session's current panes."
+ },
+ {
+ "id": "M:LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "GetWindowsAsync",
+ "kind": "method",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "genericParameters": [],
+ "returnType": "Task>",
+ "parameters": [
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task> LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reads the session's current windows."
+ },
+ {
+ "id": "M:LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken)",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "GetPanesAsync",
+ "kind": "method",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "genericParameters": [],
+ "returnType": "Task>",
+ "parameters": [
+ {
+ "name": "cancellationToken",
+ "type": "CancellationToken",
+ "default": "default"
+ }
+ ],
+ "signature": "Task> LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken cancellationToken = default)",
+ "performsIO": true,
+ "processBacked": true,
+ "portable": true,
+ "platformAnnotations": [],
+ "summary": "Reads the window's current panes."
+ },
+ {
+ "id": "M:LibTmux.Query.AndNode.#ctor(IReadOnlyList)",
+ "declaringType": "T:LibTmux.Query.AndNode",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "AndNode",
+ "parameters": [
+ {
+ "name": "operands",
+ "type": "IReadOnlyList"
+ }
+ ],
+ "signature": "AndNode(IReadOnlyList operands)",
+ "portable": true,
+ "summary": "Creates AndNode."
+ },
+ {
+ "id": "M:LibTmux.Query.BooleanConstant.#ctor(bool)",
+ "declaringType": "T:LibTmux.Query.BooleanConstant",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "BooleanConstant",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "bool"
+ }
+ ],
+ "signature": "BooleanConstant(bool value)",
+ "portable": true,
+ "summary": "Creates BooleanConstant."
+ },
+ {
+ "id": "M:LibTmux.Query.ComparisonNode.#ctor(QueryComparison,QueryNode,QueryNode)",
+ "declaringType": "T:LibTmux.Query.ComparisonNode",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "ComparisonNode",
+ "parameters": [
+ {
+ "name": "comparison",
+ "type": "QueryComparison"
+ },
+ {
+ "name": "left",
+ "type": "QueryNode"
+ },
+ {
+ "name": "right",
+ "type": "QueryNode"
+ }
+ ],
+ "signature": "ComparisonNode(QueryComparison comparison, QueryNode left, QueryNode right)",
+ "portable": true,
+ "summary": "Creates ComparisonNode."
+ },
+ {
+ "id": "M:LibTmux.Query.ConstantNode.#ctor(QueryConstant)",
+ "declaringType": "T:LibTmux.Query.ConstantNode",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "ConstantNode",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "QueryConstant"
+ }
+ ],
+ "signature": "ConstantNode(QueryConstant value)",
+ "portable": true,
+ "summary": "Creates ConstantNode."
+ },
+ {
+ "id": "M:LibTmux.Query.EnumConstant.#ctor(string,string)",
+ "declaringType": "T:LibTmux.Query.EnumConstant",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "EnumConstant",
+ "parameters": [
+ {
+ "name": "type",
+ "type": "string"
+ },
+ {
+ "name": "value",
+ "type": "string"
+ }
+ ],
+ "signature": "EnumConstant(string type, string value)",
+ "portable": true,
+ "summary": "Creates EnumConstant."
+ },
+ {
+ "id": "M:LibTmux.Query.FieldNode.#ctor(QueryTarget,string)",
+ "declaringType": "T:LibTmux.Query.FieldNode",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "FieldNode",
+ "parameters": [
+ {
+ "name": "target",
+ "type": "QueryTarget"
+ },
+ {
+ "name": "wireName",
+ "type": "string"
+ }
+ ],
+ "signature": "FieldNode(QueryTarget target, string wireName)",
+ "portable": true,
+ "summary": "Creates FieldNode."
+ },
+ {
+ "id": "M:LibTmux.Query.InstantConstant.#ctor(long)",
+ "declaringType": "T:LibTmux.Query.InstantConstant",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "InstantConstant",
+ "parameters": [
+ {
+ "name": "unixSeconds",
+ "type": "long"
+ }
+ ],
"signature": "InstantConstant(long unixSeconds)",
"portable": true,
"summary": "Creates InstantConstant."
@@ -18652,14 +19106,434 @@
"summary": "Gets Toggle."
},
{
- "id": "P:LibTmux.Query.AndNode.Operands",
- "declaringType": "T:LibTmux.Query.AndNode",
- "name": "Operands",
+ "id": "P:LibTmux.PsmuxCaptureOptions.EndLine",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
+ "name": "EndLine",
"kind": "property",
"visibility": "public",
"package": "LibTmux",
"static": false,
- "returnType": "IReadOnlyList",
+ "returnType": "CapturePanePosition?",
+ "parameters": [],
+ "signature": "CapturePanePosition? LibTmux.PsmuxCaptureOptions.EndLine { get; }",
+ "portable": true,
+ "summary": "Gets the last capture line."
+ },
+ {
+ "id": "P:LibTmux.PsmuxCaptureOptions.EscapeSequences",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
+ "name": "EscapeSequences",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "bool",
+ "parameters": [],
+ "signature": "bool LibTmux.PsmuxCaptureOptions.EscapeSequences { get; }",
+ "portable": true,
+ "summary": "Gets whether terminal escape sequences are preserved."
+ },
+ {
+ "id": "P:LibTmux.PsmuxCaptureOptions.JoinWrappedLines",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
+ "name": "JoinWrappedLines",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "bool",
+ "parameters": [],
+ "signature": "bool LibTmux.PsmuxCaptureOptions.JoinWrappedLines { get; }",
+ "portable": true,
+ "summary": "Gets whether wrapped screen rows are joined."
+ },
+ {
+ "id": "P:LibTmux.PsmuxCaptureOptions.StartLine",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
+ "name": "StartLine",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "CapturePanePosition?",
+ "parameters": [],
+ "signature": "CapturePanePosition? LibTmux.PsmuxCaptureOptions.StartLine { get; }",
+ "portable": true,
+ "summary": "Gets the first capture line."
+ },
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "DataDirectory",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxConnectionOptions.DataDirectory { get; }",
+ "portable": true,
+ "summary": "Gets the canonical isolated Windows data directory."
+ },
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.ExecutablePath",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "ExecutablePath",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxConnectionOptions.ExecutablePath { get; }",
+ "portable": true,
+ "summary": "Gets the absolute psmux client executable path."
+ },
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "ExpectedBinarySha256",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256 { get; }",
+ "portable": true,
+ "summary": "Gets the expected executable SHA-256."
+ },
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.Logger",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "Logger",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "ILogger?",
+ "parameters": [],
+ "signature": "ILogger? LibTmux.PsmuxConnectionOptions.Logger { get; }",
+ "portable": true,
+ "summary": "Gets the optional connection logger."
+ },
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.NamespaceName",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "NamespaceName",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxConnectionOptions.NamespaceName { get; }",
+ "portable": true,
+ "summary": "Gets the explicit non-default psmux namespace."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Height",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Height",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxPane.Height { get; }",
+ "portable": true,
+ "summary": "Gets the captured pane height."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Id",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Id",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "PaneId",
+ "parameters": [],
+ "signature": "PaneId LibTmux.PsmuxPane.Id { get; }",
+ "portable": true,
+ "summary": "Gets the captured pane identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Index",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Index",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxPane.Index { get; }",
+ "portable": true,
+ "summary": "Gets the captured pane index."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Server",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Server",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "PsmuxServer",
+ "parameters": [],
+ "signature": "PsmuxServer LibTmux.PsmuxPane.Server { get; }",
+ "portable": true,
+ "summary": "Gets the psmux endpoint that produced the observation."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.SessionId",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "SessionId",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "SessionId",
+ "parameters": [],
+ "signature": "SessionId LibTmux.PsmuxPane.SessionId { get; }",
+ "portable": true,
+ "summary": "Gets the captured parent session identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Title",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Title",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string?",
+ "parameters": [],
+ "signature": "string? LibTmux.PsmuxPane.Title { get; }",
+ "portable": true,
+ "summary": "Gets the captured pane title."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.Width",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "Width",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxPane.Width { get; }",
+ "portable": true,
+ "summary": "Gets the captured pane width."
+ },
+ {
+ "id": "P:LibTmux.PsmuxPane.WindowId",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "WindowId",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "WindowId",
+ "parameters": [],
+ "signature": "WindowId LibTmux.PsmuxPane.WindowId { get; }",
+ "portable": true,
+ "summary": "Gets the captured parent window identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxServer.ConnectionOptions",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "ConnectionOptions",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "PsmuxConnectionOptions",
+ "parameters": [],
+ "signature": "PsmuxConnectionOptions LibTmux.PsmuxServer.ConnectionOptions { get; }",
+ "portable": true,
+ "summary": "Gets the endpoint trust and routing settings."
+ },
+ {
+ "id": "P:LibTmux.PsmuxServer.Version",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "Version",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "TmuxVersion",
+ "parameters": [],
+ "signature": "TmuxVersion LibTmux.PsmuxServer.Version { get; }",
+ "portable": true,
+ "summary": "Gets the psmux compatibility version."
+ },
+ {
+ "id": "P:LibTmux.PsmuxSession.Attached",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "Attached",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "bool",
+ "parameters": [],
+ "signature": "bool LibTmux.PsmuxSession.Attached { get; }",
+ "portable": true,
+ "summary": "Gets whether a client was attached when observed."
+ },
+ {
+ "id": "P:LibTmux.PsmuxSession.Id",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "Id",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "SessionId",
+ "parameters": [],
+ "signature": "SessionId LibTmux.PsmuxSession.Id { get; }",
+ "portable": true,
+ "summary": "Gets the captured session identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxSession.Name",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "Name",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxSession.Name { get; }",
+ "portable": true,
+ "summary": "Gets the captured session name."
+ },
+ {
+ "id": "P:LibTmux.PsmuxSession.Server",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "Server",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "PsmuxServer",
+ "parameters": [],
+ "signature": "PsmuxServer LibTmux.PsmuxSession.Server { get; }",
+ "portable": true,
+ "summary": "Gets the psmux endpoint that produced the observation."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Height",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Height",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxWindow.Height { get; }",
+ "portable": true,
+ "summary": "Gets the captured window height."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Id",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Id",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "WindowId",
+ "parameters": [],
+ "signature": "WindowId LibTmux.PsmuxWindow.Id { get; }",
+ "portable": true,
+ "summary": "Gets the captured window identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Index",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Index",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxWindow.Index { get; }",
+ "portable": true,
+ "summary": "Gets the captured window index."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Name",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Name",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "string",
+ "parameters": [],
+ "signature": "string LibTmux.PsmuxWindow.Name { get; }",
+ "portable": true,
+ "summary": "Gets the captured window name."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Server",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Server",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "PsmuxServer",
+ "parameters": [],
+ "signature": "PsmuxServer LibTmux.PsmuxWindow.Server { get; }",
+ "portable": true,
+ "summary": "Gets the psmux endpoint that produced the observation."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.SessionId",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "SessionId",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "SessionId",
+ "parameters": [],
+ "signature": "SessionId LibTmux.PsmuxWindow.SessionId { get; }",
+ "portable": true,
+ "summary": "Gets the captured parent session identifier."
+ },
+ {
+ "id": "P:LibTmux.PsmuxWindow.Width",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "Width",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "int",
+ "parameters": [],
+ "signature": "int LibTmux.PsmuxWindow.Width { get; }",
+ "portable": true,
+ "summary": "Gets the captured window width."
+ },
+ {
+ "id": "P:LibTmux.Query.AndNode.Operands",
+ "declaringType": "T:LibTmux.Query.AndNode",
+ "name": "Operands",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "IReadOnlyList",
"parameters": [],
"signature": "IReadOnlyList LibTmux.Query.AndNode.Operands { get; }",
"portable": true,
@@ -23005,6 +23879,66 @@
"signature": "enum LibTmux.PromptType",
"portable": true
},
+ {
+ "id": "T:LibTmux.PsmuxCaptureOptions",
+ "declaringType": "T:LibTmux.PsmuxCaptureOptions",
+ "name": "PsmuxCaptureOptions",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxCaptureOptions",
+ "portable": true
+ },
+ {
+ "id": "T:LibTmux.PsmuxConnectionOptions",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "PsmuxConnectionOptions",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxConnectionOptions",
+ "portable": true
+ },
+ {
+ "id": "T:LibTmux.PsmuxPane",
+ "declaringType": "T:LibTmux.PsmuxPane",
+ "name": "PsmuxPane",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxPane",
+ "portable": true
+ },
+ {
+ "id": "T:LibTmux.PsmuxServer",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "PsmuxServer",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxServer",
+ "portable": true
+ },
+ {
+ "id": "T:LibTmux.PsmuxSession",
+ "declaringType": "T:LibTmux.PsmuxSession",
+ "name": "PsmuxSession",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxSession",
+ "portable": true
+ },
+ {
+ "id": "T:LibTmux.PsmuxWindow",
+ "declaringType": "T:LibTmux.PsmuxWindow",
+ "name": "PsmuxWindow",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "class LibTmux.PsmuxWindow",
+ "portable": true
+ },
{
"id": "T:LibTmux.Query.AndNode",
"declaringType": "T:LibTmux.Query.AndNode",
@@ -24015,6 +24949,16 @@
"signature": "abstract record LibTmux.TmuxEvent",
"portable": true
},
+ {
+ "id": "T:LibTmux.TmuxEventsDroppedEvent",
+ "declaringType": "T:LibTmux.TmuxEventsDroppedEvent",
+ "name": "TmuxEventsDroppedEvent",
+ "kind": "type",
+ "visibility": "public",
+ "package": "LibTmux",
+ "signature": "record LibTmux.TmuxEventsDroppedEvent",
+ "portable": true
+ },
{
"id": "T:LibTmux.TmuxOutputEvent",
"declaringType": "T:LibTmux.TmuxOutputEvent",
@@ -24057,15 +25001,22 @@
"parameters": [
{
"name": "target",
- "type": "string?"
+ "type": "string?",
+ "default": "null"
},
{
"name": "cancellationToken",
- "type": "System.Threading.CancellationToken"
+ "type": "CancellationToken",
+ "default": "default"
}
],
"signature": "Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)",
- "portable": true,
+ "performsIO": true,
+ "processBacked": true,
+ "portable": false,
+ "platformAnnotations": [
+ "UnsupportedOSPlatform(\"windows\")"
+ ],
"summary": "Starts a tmux control client and keeps it running."
},
{
@@ -24221,6 +25172,57 @@
"portable": true,
"summary": "Gets Arguments."
},
+ {
+ "id": "M:LibTmux.TmuxEventsDroppedEvent.#ctor(long,long)",
+ "declaringType": "T:LibTmux.TmuxEventsDroppedEvent",
+ "name": ".ctor",
+ "kind": "constructor",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "TmuxEventsDroppedEvent",
+ "parameters": [
+ {
+ "name": "Count",
+ "type": "long"
+ },
+ {
+ "name": "TotalDropped",
+ "type": "long"
+ }
+ ],
+ "signature": "TmuxEventsDroppedEvent(long Count, long TotalDropped)",
+ "portable": true,
+ "summary": "Creates a bounded-event-buffer loss marker."
+ },
+ {
+ "id": "P:LibTmux.TmuxEventsDroppedEvent.Count",
+ "declaringType": "T:LibTmux.TmuxEventsDroppedEvent",
+ "name": "Count",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "long",
+ "parameters": [],
+ "signature": "long LibTmux.TmuxEventsDroppedEvent.Count { get; }",
+ "portable": true,
+ "summary": "Gets the events discarded since the previous loss report."
+ },
+ {
+ "id": "P:LibTmux.TmuxEventsDroppedEvent.TotalDropped",
+ "declaringType": "T:LibTmux.TmuxEventsDroppedEvent",
+ "name": "TotalDropped",
+ "kind": "property",
+ "visibility": "public",
+ "package": "LibTmux",
+ "static": false,
+ "returnType": "long",
+ "parameters": [],
+ "signature": "long LibTmux.TmuxEventsDroppedEvent.TotalDropped { get; }",
+ "portable": true,
+ "summary": "Gets the events discarded over this control client's lifetime."
+ },
{
"id": "M:LibTmux.TmuxExitEvent.#ctor(string?)",
"declaringType": "T:LibTmux.TmuxExitEvent",
diff --git a/docs/public-api.md b/docs/public-api.md
index 7c4601a..8fc1d36 100644
--- a/docs/public-api.md
+++ b/docs/public-api.md
@@ -23,9 +23,10 @@ The complete parsing, ordering, detection, and support contract follows.
```json
{
"grammar": [
- "version = next / release / prerelease",
+ "version = next / release / micro / prerelease",
"next = \"next-\" core",
"release = core [patch] [\"-openbsd\"]",
+ "micro = core \".\" uint",
"prerelease = core (\"-rc\" posint / \"-dev\" [\".\" uint])",
"core = uint \".\" uint",
"patch = 1*LOWER",
@@ -37,6 +38,7 @@ The complete parsing, ordering, detection, and support contract follows.
"majorMinor": "the two invariant-culture decimal core components",
"suffixExamples": {
"3.7": null,
+ "3.3.7": "7",
"3.7b": "b",
"3.0-rc3": "rc3",
"3.3a-openbsd": "a-openbsd",
@@ -60,7 +62,7 @@ The complete parsing, ordering, detection, and support contract follows.
"03.7",
"3.07",
"3.7B",
- "3.7.1",
+ "3.7.01",
"3.7-",
"+3.7",
"integer component overflow"
@@ -68,15 +70,17 @@ The complete parsing, ordering, detection, and support contract follows.
},
"ordering": {
"core": "major then minor, numerically ascending",
- "sameCore": "next < dev < rcN < final < letter patch",
+ "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch",
"development": "a missing dev number precedes numeric dev numbers",
"releaseCandidate": "N compares numerically",
+ "micro": "N compares numerically",
"patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27",
"vendor": "-openbsd immediately follows its corresponding final or patch release",
"exactIdentity": "CompareTo returns zero if and only if equality is true",
"examples": [
"next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2",
"3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b",
+ "3.3 < 3.3.1 < 3.3.10 < 3.3a",
"3.7b < next-3.8 < 3.8"
],
"invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid",
@@ -326,6 +330,12 @@ internal static class Program
| `T:LibTmux.PipePaneRequest` | record | `public, sealed` | None | `object` | value | Parameters for PipePane. | `LibTmux` |
| `T:LibTmux.PopupCloseMode` | enum | `public` | None | `Enum` | value | Defines PopupCloseMode values. | `LibTmux` |
| `T:LibTmux.PromptType` | enum | `public` | None | `Enum` | value | Defines PromptType values. | `LibTmux` |
+| `T:LibTmux.PsmuxCaptureOptions` | class | `public, sealed` | None | `object` | value | Typed capture choices audited for the psmux query preview. | `LibTmux` |
+| `T:LibTmux.PsmuxConnectionOptions` | class | `public, sealed` | None | `object` | value | A pinned psmux client file and one isolated namespace. | `LibTmux` |
+| `T:LibTmux.PsmuxPane` | class | `public, sealed` | None | `object` | value | An immutable pane observation from the psmux query preview. | `LibTmux` |
+| `T:LibTmux.PsmuxServer` | class | `public, sealed` | None | `object` | reference | A query-only connection to one isolated psmux namespace. | `LibTmux` |
+| `T:LibTmux.PsmuxSession` | class | `public, sealed` | None | `object` | value | An immutable observation of the sole psmux session. | `LibTmux` |
+| `T:LibTmux.PsmuxWindow` | class | `public, sealed` | None | `object` | value | An immutable window observation from the psmux query preview. | `LibTmux` |
| `T:LibTmux.Query.AndNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical and query node. Equality: structural ordered operand equality and hashing. | `LibTmux` |
| `T:LibTmux.Query.BooleanConstant` | record | `public, sealed` | None | `QueryConstant` | value | A canonical boolean constant. | `LibTmux` |
| `T:LibTmux.Query.ComparisonNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical comparison query node. | `LibTmux` |
@@ -428,6 +438,7 @@ internal static class Program
| `T:LibTmux.WindowRotationDirection` | enum | `public` | None | `Enum` | value | Defines WindowRotationDirection values. | `LibTmux` |
| `T:LibTmux.IControlModeSession` | interface | `public` | `System.IAsyncDisposable` | `None` | reference | A live tmux control client reporting what tmux does until disposed. | `LibTmux` |
| `T:LibTmux.TmuxEvent` | record | `public, abstract` | None | `object` | value | One thing a tmux control client reported without being asked. | `LibTmux` |
+| `T:LibTmux.TmuxEventsDroppedEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | A loss marker emitted when the bounded control-event buffer overflows. | `LibTmux` |
| `T:LibTmux.TmuxOutputEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | Bytes a pane wrote, with tmux's escaping decoded. | `LibTmux` |
| `T:LibTmux.TmuxNotificationEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | A tmux notification carried by name with its words unparsed. | `LibTmux` |
| `T:LibTmux.TmuxExitEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | The control client ended; always the last event in the stream. | `LibTmux` |
@@ -724,6 +735,8 @@ internal static class Program
| Member ID | Declaration | Visibility | Static | Platform | Notes |
| --- | --- | --- | --- | --- | --- |
| `M:LibTmux.LibTmuxException.#ctor(string,Exception?)` | `LibTmuxException(string message, Exception? innerException = null)` | Public | No | Portable | Creates LibTmuxException. |
+| `M:LibTmux.LibTmuxException.#ctor(string,TmuxDispatchState,Exception?)` | `LibTmuxException(string message, TmuxDispatchState dispatch, Exception? innerException = null)` | Public | No | Portable | Creates LibTmuxException with a known dispatch state. |
+| `P:LibTmux.LibTmuxException.Dispatch` | `TmuxDispatchState LibTmux.LibTmuxException.Dispatch { get; }` | Public | No | Portable | Gets whether the command reached tmux, and so whether a retry is safe. |
### `T:LibTmux.LibTmuxInfo`
@@ -1009,6 +1022,80 @@ internal static class Program
| `F:LibTmux.PromptType.Target` | `Target = 2` | Public | Implicit | Portable | The Target value. Value: `2`. |
| `F:LibTmux.PromptType.WindowTarget` | `WindowTarget = 3` | Public | Implicit | Portable | The WindowTarget value. Value: `3`. |
+### `T:LibTmux.PsmuxCaptureOptions`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.PsmuxCaptureOptions.#ctor(CapturePanePosition?,CapturePanePosition?,bool,bool)` | `PsmuxCaptureOptions(CapturePanePosition? startLine = null, CapturePanePosition? endLine = null, bool escapeSequences = false, bool joinWrappedLines = false)` | Public | No | Portable | Creates an audited psmux capture request. |
+| `P:LibTmux.PsmuxCaptureOptions.EndLine` | `CapturePanePosition? LibTmux.PsmuxCaptureOptions.EndLine { get; }` | Public | No | Portable | Gets the last capture line. |
+| `P:LibTmux.PsmuxCaptureOptions.EscapeSequences` | `bool LibTmux.PsmuxCaptureOptions.EscapeSequences { get; }` | Public | No | Portable | Gets whether terminal escape sequences are preserved. |
+| `P:LibTmux.PsmuxCaptureOptions.JoinWrappedLines` | `bool LibTmux.PsmuxCaptureOptions.JoinWrappedLines { get; }` | Public | No | Portable | Gets whether wrapped screen rows are joined. |
+| `P:LibTmux.PsmuxCaptureOptions.StartLine` | `CapturePanePosition? LibTmux.PsmuxCaptureOptions.StartLine { get; }` | Public | No | Portable | Gets the first capture line. |
+
+### `T:LibTmux.PsmuxConnectionOptions`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.PsmuxConnectionOptions.#ctor(string,string,string,string,ILogger?)` | `PsmuxConnectionOptions(string executablePath, string expectedBinarySha256, string dataDirectory, string namespaceName, ILogger? logger = null)` | Public | No | Portable | Creates one pinned client and isolated psmux endpoint. |
+| `P:LibTmux.PsmuxConnectionOptions.DataDirectory` | `string LibTmux.PsmuxConnectionOptions.DataDirectory { get; }` | Public | No | Portable | Gets the canonical isolated Windows data directory. |
+| `P:LibTmux.PsmuxConnectionOptions.ExecutablePath` | `string LibTmux.PsmuxConnectionOptions.ExecutablePath { get; }` | Public | No | Portable | Gets the absolute psmux client executable path. |
+| `P:LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256` | `string LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256 { get; }` | Public | No | Portable | Gets the expected executable SHA-256. |
+| `P:LibTmux.PsmuxConnectionOptions.Logger` | `ILogger? LibTmux.PsmuxConnectionOptions.Logger { get; }` | Public | No | Portable | Gets the optional connection logger. |
+| `P:LibTmux.PsmuxConnectionOptions.NamespaceName` | `string LibTmux.PsmuxConnectionOptions.NamespaceName { get; }` | Public | No | Portable | Gets the explicit non-default psmux namespace. |
+
+### `T:LibTmux.PsmuxPane`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions?,CancellationToken)` | `Task> LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions? options = null, CancellationToken cancellationToken = default)` | Public | No | Portable | Captures pane text with best-effort target consistency. |
+| `P:LibTmux.PsmuxPane.Height` | `int LibTmux.PsmuxPane.Height { get; }` | Public | No | Portable | Gets the captured pane height. |
+| `P:LibTmux.PsmuxPane.Id` | `PaneId LibTmux.PsmuxPane.Id { get; }` | Public | No | Portable | Gets the captured pane identifier. |
+| `P:LibTmux.PsmuxPane.Index` | `int LibTmux.PsmuxPane.Index { get; }` | Public | No | Portable | Gets the captured pane index. |
+| `P:LibTmux.PsmuxPane.Server` | `PsmuxServer LibTmux.PsmuxPane.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. |
+| `P:LibTmux.PsmuxPane.SessionId` | `SessionId LibTmux.PsmuxPane.SessionId { get; }` | Public | No | Portable | Gets the captured parent session identifier. |
+| `P:LibTmux.PsmuxPane.Title` | `string? LibTmux.PsmuxPane.Title { get; }` | Public | No | Portable | Gets the captured pane title. |
+| `P:LibTmux.PsmuxPane.Width` | `int LibTmux.PsmuxPane.Width { get; }` | Public | No | Portable | Gets the captured pane width. |
+| `P:LibTmux.PsmuxPane.WindowId` | `WindowId LibTmux.PsmuxPane.WindowId { get; }` | Public | No | Portable | Gets the captured parent window identifier. |
+
+### `T:LibTmux.PsmuxServer`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `F:LibTmux.PsmuxServer.SupportedBinarySha256` | `static const string LibTmux.PsmuxServer.SupportedBinarySha256` | Public | Yes | Portable | The exact psmux client executable SHA-256 accepted by this preview. Value: `1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e`. |
+| `F:LibTmux.PsmuxServer.SupportedCommit` | `static const string LibTmux.PsmuxServer.SupportedCommit` | Public | Yes | Portable | The exact psmux source commit accepted by this preview. Value: `aa26cd39edcfab03e718f94ea21bb47e8c5b85e8`. |
+| `F:LibTmux.PsmuxServer.SupportedImplementationBanner` | `static const string LibTmux.PsmuxServer.SupportedImplementationBanner` | Public | Yes | Portable | The exact clean implementation banner accepted by this preview. Value: `psmux 3.3.7 (aa26cd3 2026-08-17)`. |
+| `M:LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions,CancellationToken)` | `static Task LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions options, CancellationToken cancellationToken = default)` | Public | Yes | Portable | Connects through the pinned client and validates one session. |
+| `M:LibTmux.PsmuxServer.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxServer.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads all current panes in the sole visible session. |
+| `M:LibTmux.PsmuxServer.GetSessionAsync(CancellationToken)` | `Task LibTmux.PsmuxServer.GetSessionAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the sole visible session or fails closed. |
+| `M:LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken)` | `Task> LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads all current windows in the sole visible session. |
+| `M:LibTmux.PsmuxServer.RefreshAsync(CancellationToken)` | `Task LibTmux.PsmuxServer.RefreshAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reconnects and returns a fresh endpoint observation. |
+| `P:LibTmux.PsmuxServer.ConnectionOptions` | `PsmuxConnectionOptions LibTmux.PsmuxServer.ConnectionOptions { get; }` | Public | No | Portable | Gets the endpoint trust and routing settings. |
+| `P:LibTmux.PsmuxServer.Version` | `TmuxVersion LibTmux.PsmuxServer.Version { get; }` | Public | No | Portable | Gets the psmux compatibility version. |
+
+### `T:LibTmux.PsmuxSession`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.PsmuxSession.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxSession.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the session's current panes. |
+| `M:LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken)` | `Task> LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the session's current windows. |
+| `P:LibTmux.PsmuxSession.Attached` | `bool LibTmux.PsmuxSession.Attached { get; }` | Public | No | Portable | Gets whether a client was attached when observed. |
+| `P:LibTmux.PsmuxSession.Id` | `SessionId LibTmux.PsmuxSession.Id { get; }` | Public | No | Portable | Gets the captured session identifier. |
+| `P:LibTmux.PsmuxSession.Name` | `string LibTmux.PsmuxSession.Name { get; }` | Public | No | Portable | Gets the captured session name. |
+| `P:LibTmux.PsmuxSession.Server` | `PsmuxServer LibTmux.PsmuxSession.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. |
+
+### `T:LibTmux.PsmuxWindow`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the window's current panes. |
+| `P:LibTmux.PsmuxWindow.Height` | `int LibTmux.PsmuxWindow.Height { get; }` | Public | No | Portable | Gets the captured window height. |
+| `P:LibTmux.PsmuxWindow.Id` | `WindowId LibTmux.PsmuxWindow.Id { get; }` | Public | No | Portable | Gets the captured window identifier. |
+| `P:LibTmux.PsmuxWindow.Index` | `int LibTmux.PsmuxWindow.Index { get; }` | Public | No | Portable | Gets the captured window index. |
+| `P:LibTmux.PsmuxWindow.Name` | `string LibTmux.PsmuxWindow.Name { get; }` | Public | No | Portable | Gets the captured window name. |
+| `P:LibTmux.PsmuxWindow.Server` | `PsmuxServer LibTmux.PsmuxWindow.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. |
+| `P:LibTmux.PsmuxWindow.SessionId` | `SessionId LibTmux.PsmuxWindow.SessionId { get; }` | Public | No | Portable | Gets the captured parent session identifier. |
+| `P:LibTmux.PsmuxWindow.Width` | `int LibTmux.PsmuxWindow.Width { get; }` | Public | No | Portable | Gets the captured window width. |
+
### `T:LibTmux.Query.AndNode`
| Member ID | Declaration | Visibility | Static | Platform | Notes |
@@ -1334,7 +1421,7 @@ internal static class Program
| `M:LibTmux.Server.DetachAllClientsAsync(string?,string?,CancellationToken)` | `Task LibTmux.Server.DetachAllClientsAsync(string? keepClient = null, string? shellCommand = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DetachAllClients. |
| `M:LibTmux.Server.DetachClientAsync(string?,string?,CancellationToken)` | `Task LibTmux.Server.DetachClientAsync(string? targetClient = null, string? shellCommand = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DetachClient. |
| `M:LibTmux.Server.DisplayMessageAsync(DisplayMessageRequest,CancellationToken)` | `Task?> LibTmux.Server.DisplayMessageAsync(DisplayMessageRequest request, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DisplayMessage. |
-| `M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)` | `Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)` | Public | No | Portable | Starts a tmux control client and keeps it running. |
+| `M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)` | `Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Starts a tmux control client and keeps it running. |
| `M:LibTmux.Server.ExecuteCommandAsync(IReadOnlyList,CancellationToken)` | `Task LibTmux.Server.ExecuteCommandAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Executes one raw tmux command and returns both byte streams. |
| `M:LibTmux.Server.FromEnvironment(IReadOnlyDictionary?)` | `static Server LibTmux.Server.FromEnvironment(IReadOnlyDictionary? environment = null)` | Public | Yes | Portable | Parses a tmux endpoint from an environment snapshot without starting a process. |
| `M:LibTmux.Server.GetAttachedSessionsAsync(CancellationToken)` | `Task> LibTmux.Server.GetAttachedSessionsAsync(CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Returns attached sessions or captured empty on any list-command failure. List error policy: empty-on-any-list-command-failure. |
@@ -1827,6 +1914,14 @@ internal static class Program
| `P:LibTmux.TmuxCommandResult.StandardOutput` | `ReadOnlyMemory LibTmux.TmuxCommandResult.StandardOutput { get; }` | Public | No | Portable | Gets StandardOutput. |
| `P:LibTmux.TmuxCommandResult.StandardOutputLines` | `IReadOnlyList LibTmux.TmuxCommandResult.StandardOutputLines { get; }` | Public | No | Portable | Gets StandardOutputLines. |
+### `T:LibTmux.TmuxDispatchState`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `F:LibTmux.TmuxDispatchState.Dispatched` | `Dispatched = 2` | Public | Implicit | Portable | tmux ran the command and answered, so any side effect has already happened. Value: `2`. |
+| `F:LibTmux.TmuxDispatchState.NotDispatched` | `NotDispatched = 1` | Public | Implicit | Portable | The command never reached tmux, so a retry repeats nothing. Value: `1`. |
+| `F:LibTmux.TmuxDispatchState.Unknown` | `Unknown = 0` | Public | Implicit | Portable | Whether tmux acted on the command cannot be determined; treat a retry as able to repeat it. Value: `0`. |
+
### `T:LibTmux.TmuxEnvironment`
| Member ID | Declaration | Visibility | Static | Platform | Notes |
@@ -1846,6 +1941,14 @@ internal static class Program
| `P:LibTmux.TmuxEnvironmentEntry.Name` | `string LibTmux.TmuxEnvironmentEntry.Name { get; }` | Public | No | Portable | Gets Name. |
| `P:LibTmux.TmuxEnvironmentEntry.Value` | `string? LibTmux.TmuxEnvironmentEntry.Value { get; }` | Public | No | Portable | Gets Value. |
+### `T:LibTmux.TmuxEventsDroppedEvent`
+
+| Member ID | Declaration | Visibility | Static | Platform | Notes |
+| --- | --- | --- | --- | --- | --- |
+| `M:LibTmux.TmuxEventsDroppedEvent.#ctor(long,long)` | `TmuxEventsDroppedEvent(long Count, long TotalDropped)` | Public | No | Portable | Creates a bounded-event-buffer loss marker. |
+| `P:LibTmux.TmuxEventsDroppedEvent.Count` | `long LibTmux.TmuxEventsDroppedEvent.Count { get; }` | Public | No | Portable | Gets the events discarded since the previous loss report. |
+| `P:LibTmux.TmuxEventsDroppedEvent.TotalDropped` | `long LibTmux.TmuxEventsDroppedEvent.TotalDropped { get; }` | Public | No | Portable | Gets the events discarded over this control client's lifetime. |
+
### `T:LibTmux.TmuxExitEvent`
| Member ID | Declaration | Visibility | Static | Platform | Notes |
@@ -1985,6 +2088,7 @@ internal static class Program
| Member ID | Declaration | Visibility | Static | Platform | Notes |
| --- | --- | --- | --- | --- | --- |
| `M:LibTmux.TmuxTransportException.#ctor(string,IReadOnlyList,Exception?)` | `TmuxTransportException(string message, IReadOnlyList arguments, Exception? innerException = null)` | Public | No | Portable | Creates TmuxTransportException. |
+| `M:LibTmux.TmuxTransportException.#ctor(string,IReadOnlyList,TmuxDispatchState,Exception?)` | `TmuxTransportException(string message, IReadOnlyList arguments, TmuxDispatchState dispatch, Exception? innerException = null)` | Public | No | Portable | Creates TmuxTransportException with a known dispatch state. |
| `P:LibTmux.TmuxTransportException.Arguments` | `IReadOnlyList LibTmux.TmuxTransportException.Arguments { get; }` | Public | No | Portable | Gets Arguments. |
### `T:LibTmux.TmuxVersion`
diff --git a/docs/quality-bar.md b/docs/quality-bar.md
index 4409d51..26fc171 100644
--- a/docs/quality-bar.md
+++ b/docs/quality-bar.md
@@ -1,11 +1,15 @@
-# Quality bar
+# Quality bar (archived snapshot)
+
+> **Archived evidence — not a current release claim.** This page records one
+> older tree. Its API counts, macOS results, and Linux NativeAOT observations
+> must not be cited for the alpha.8 tree; use fresh CI and release evidence.
A rating is worthless as an assertion, so this is the rubric and the evidence
behind each score. Every row names something a reader can check, and the check
is a command or a file rather than a claim.
-Assessed at `acd37f8` + the documentation work that follows it, against tmux
-3.2a–3.7b on net8.0 and net10.0.
+This snapshot was assessed at `acd37f8` plus the documentation work that
+followed it, against tmux 3.2a–3.7b on net8.0 and net10.0.
Scoring: **10** means nothing known is missing. **9.5** means the gaps are named
and are not defects. Below 9 means something is wrong rather than absent.
@@ -100,11 +104,12 @@ published, versioned doc site.
---
-## How to re-check this
+## How to measure a current tree
```console
$ bash eng/quality/measure.sh
```
-The numbers above come from that script, so a change that moves one moves the
-evidence rather than only the prose.
+The script prints current raw measures; it does not refresh this archived prose.
+Current compatibility, package, API, and platform claims require their named CI
+or release gates on the exact tree being assessed.
diff --git a/eng/docs/render_api_reference.py b/eng/docs/render_api_reference.py
index 37da7f2..342f13a 100644
--- a/eng/docs/render_api_reference.py
+++ b/eng/docs/render_api_reference.py
@@ -1,22 +1,24 @@
"""Render the API reference from the compiler's own XML documentation.
-The reference is generated from ``LibTmux.xml`` rather than from the approved
-contract, because that file is what the compiler wrote down from the doc
-comments on the members themselves. A member whose comment is missing is
-missing here too, which is the point: the page is evidence about the comments
-rather than a second place to write them.
+The reference takes summaries from ``LibTmux.xml`` and visibility from the
+approved contract. The compiler XML contains comments for internal helpers too;
+only exact public member identifiers approved for the core package may render.
"""
from __future__ import annotations
import argparse
+from collections import Counter, defaultdict
+import json
import pathlib
+import re
import sys
import typing as t
from xml.etree import ElementTree
CSHARP_ROOT = pathlib.Path(__file__).parents[2]
OUTPUT_PATH = CSHARP_ROOT / "docs" / "api" / "README.md"
+PUBLIC_API_PATH = CSHARP_ROOT / "docs" / "public-api.json"
KIND_TITLES = {
"T": "Types",
"M": "Methods",
@@ -24,11 +26,151 @@
"F": "Fields",
"E": "Events",
}
+MemberShape = tuple[str, str, str, int, int]
def documentation_paths() -> list[pathlib.Path]:
- """Return every built XML documentation file."""
- return sorted((CSHARP_ROOT / "src").glob("*/bin/*/net*/LibTmux*.xml"))
+ """Return built core XML documentation, preferring the CI configuration."""
+ paths = (CSHARP_ROOT / "src" / "LibTmux" / "bin").glob(
+ "*/net*/LibTmux.xml"
+ )
+ return sorted(
+ paths,
+ key=lambda path: (
+ path.parent.parent.name != "Release",
+ path.parent.name != "net10.0",
+ str(path),
+ ),
+ )
+
+
+def public_type_names(path: pathlib.Path = PUBLIC_API_PATH) -> frozenset[str]:
+ """Return the contract names of public types in the core assembly."""
+ contract = json.loads(path.read_text(encoding="utf-8"))
+ return frozenset(
+ entry["id"][2:]
+ for entry in contract["types"]
+ if entry["package"] == "LibTmux" and "public" in entry["modifiers"]
+ )
+
+
+def contract_surface(
+ path: pathlib.Path = PUBLIC_API_PATH,
+) -> tuple[frozenset[str], Counter[MemberShape]]:
+ """Return approved public types and member shapes for the core assembly."""
+ contract = json.loads(path.read_text(encoding="utf-8"))
+ public_types = {
+ entry["id"]
+ for entry in contract["types"]
+ if entry["package"] == "LibTmux" and "public" in entry["modifiers"]
+ }
+ public_members = [
+ entry
+ for entry in contract["members"]
+ if entry.get("package") == "LibTmux"
+ and entry.get("visibility") in {"public", "explicit"}
+ and entry.get("declaringType") in public_types
+ and entry.get("kind") != "type"
+ ]
+ shapes: Counter[MemberShape] = Counter(
+ (
+ entry["id"][0],
+ entry["declaringType"][2:],
+ entry["name"],
+ len(entry.get("genericParameters", [])),
+ len(entry.get("parameters", [])),
+ )
+ for entry in public_members
+ )
+ return frozenset(public_types), shapes
+
+
+def public_member_ids(
+ documentation_path: pathlib.Path,
+ contract_path: pathlib.Path = PUBLIC_API_PATH,
+) -> frozenset[str]:
+ """Map approved public source IDs to exact compiler XML member IDs."""
+ public_types, approved_shapes = contract_surface(contract_path)
+ type_names = frozenset(identifier[2:] for identifier in public_types)
+ candidates: dict[MemberShape, list[str]] = defaultdict(list)
+ selected = set(public_types)
+ root = ElementTree.parse(documentation_path).getroot()
+ for member in root.findall("./members/member"):
+ name = member.get("name")
+ if name is None:
+ continue
+ shape = xml_member_shape(name, type_names)
+ if shape is not None and shape in approved_shapes:
+ candidates[shape].append(name)
+
+ for shape, identifiers in candidates.items():
+ if len(identifiers) > approved_shapes[shape]:
+ joined = ", ".join(sorted(identifiers))
+ raise ValueError(
+ "XML documentation has more members than the approved public shape "
+ f"{shape}: {joined}"
+ )
+ selected.update(identifiers)
+ return frozenset(selected)
+
+
+def xml_member_shape(
+ identifier: str,
+ public_types: frozenset[str],
+) -> MemberShape | None:
+ """Return the contract-comparable shape of one compiler XML identifier."""
+ if len(identifier) < 3 or identifier[1] != ":" or identifier[0] == "T":
+ return None
+
+ body = identifier[2:]
+ declaring = next(
+ (
+ type_name
+ for type_name in sorted(public_types, key=len, reverse=True)
+ if body.startswith(f"{type_name}.")
+ ),
+ None,
+ )
+ if declaring is None:
+ return None
+
+ member = body[len(declaring) + 1 :]
+ head, separator, parameters = member.partition("(")
+ arity_match = re.search(r"``(?P[1-9][0-9]*)$", head)
+ generic_arity = int(arity_match.group("arity")) if arity_match else 0
+ if arity_match:
+ head = head[: arity_match.start()]
+ parameter_count = count_xml_parameters(parameters) if separator else 0
+ return (
+ identifier[0],
+ declaring,
+ head.replace("#", "."),
+ generic_arity,
+ parameter_count,
+ )
+
+
+def count_xml_parameters(parameters: str) -> int:
+ """Count top-level parameters in the tail of a compiler XML identifier."""
+ closing = parameters.rfind(")")
+ if closing < 0:
+ raise ValueError("Malformed XML documentation member identifier.")
+ body = parameters[:closing]
+ if not body:
+ return 0
+
+ depth = 0
+ count = 1
+ for character in body:
+ if character in "{[":
+ depth += 1
+ elif character in "}]":
+ depth -= 1
+ elif character == "," and depth == 0:
+ count += 1
+ if depth != 0:
+ raise ValueError("Malformed XML documentation parameter list.")
+ return count
def flatten(node: ElementTree.Element | None) -> str:
@@ -39,13 +181,16 @@ def flatten(node: ElementTree.Element | None) -> str:
return " ".join("".join(node.itertext()).split())
-def read_members(path: pathlib.Path) -> dict[str, str]:
+def read_members(
+ path: pathlib.Path,
+ approved_members: frozenset[str],
+) -> dict[str, str]:
"""Return each documented member identifier and its summary."""
root = ElementTree.parse(path).getroot()
members: dict[str, str] = {}
for member in root.findall("./members/member"):
name = member.get("name")
- if name is None or ".Internal." in name:
+ if name is None or name not in approved_members:
continue
summary = flatten(member.find("summary"))
@@ -64,8 +209,8 @@ def render(members: dict[str, str]) -> str:
lines = [
"# API reference",
"",
- "Generated from the XML documentation the compiler emits, so every entry",
- "here is the doc comment on the member itself. Regenerate with",
+ "Generated from compiler XML summaries and gated by the approved public",
+ "contract, so documented internal helpers never render. Regenerate with",
"`uv run python eng/docs/render_api_reference.py`.",
"",
"See [choosing a mode](../modes/matrix.md) for how the three execution",
@@ -79,11 +224,22 @@ def render(members: dict[str, str]) -> str:
lines.extend(["", f"## {title}", "", "| Member | Summary |", "|---|---|"])
for name, summary in entries:
escaped = summary.replace("|", "\\|")
- lines.append(f"| `{name[2:]}` | {escaped} |")
+ lines.append(f"| {code_span(name[2:])} | {escaped} |")
return "\n".join(lines) + "\n"
+def code_span(value: str) -> str:
+ """Render metadata names containing generic-arity backticks as valid Markdown."""
+ longest_run = 0
+ current_run = 0
+ for character in value:
+ current_run = current_run + 1 if character == "`" else 0
+ longest_run = max(longest_run, current_run)
+ delimiter = "`" * (longest_run + 1)
+ return f"{delimiter}{value}{delimiter}"
+
+
def main(arguments: t.Sequence[str] | None = None) -> int:
"""Write the reference, or check the written one is current."""
parser = argparse.ArgumentParser(description=__doc__)
@@ -95,7 +251,7 @@ def main(arguments: t.Sequence[str] | None = None) -> int:
print("no built XML documentation found; build first", file=sys.stderr)
return 1
- rendered = render(read_members(paths[0]))
+ rendered = render(read_members(paths[0], public_member_ids(paths[0])))
if parsed.check:
current = (
OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else ""
diff --git a/eng/docs/sync_snippets.py b/eng/docs/sync_snippets.py
index e300b6e..2b8580c 100644
--- a/eng/docs/sync_snippets.py
+++ b/eng/docs/sync_snippets.py
@@ -1,8 +1,9 @@
"""Materialize example regions into the documents that publish them.
-Every published block is a ``#region`` inside a method that runs against live
-tmux in CI. This copies the region in; ``--check`` fails on drift instead of
-writing, which is what CI runs.
+Every published block is a ``#region`` inside a compiled example method. The
+ordinary tmux suite runs its examples live in CI; platform previews can require
+their documented manual harness. This copies the region in; ``--check`` fails
+on drift instead of writing, which is what CI runs.
The copy is materialized rather than transcluded because these are package
READMEs, and nuget.org renders the markdown it is given without resolving
@@ -35,10 +36,12 @@
"src/LibTmux.Query.Json/README.md",
"src/LibTmux.Workspace/README.md",
"src/LibTmux.Mcp/README.md",
+ "docs/mcp/README.md",
"docs/modes/one-shot.md",
"docs/modes/control-mode.md",
"docs/modes/chaining.md",
"docs/modes/matrix.md",
+ "docs/psmux.md",
)
REGION = re.compile(
diff --git a/eng/docs/tests/test_render_api_reference.py b/eng/docs/tests/test_render_api_reference.py
new file mode 100644
index 0000000..d66de86
--- /dev/null
+++ b/eng/docs/tests/test_render_api_reference.py
@@ -0,0 +1,273 @@
+"""Prove the API reference contains only the approved public surface."""
+
+from __future__ import annotations
+
+import pathlib
+import runpy
+import typing as t
+
+import pytest
+
+
+def load_renderer() -> dict[str, t.Any]:
+ """Load the renderer as an import-free test namespace."""
+ return runpy.run_path(
+ str(pathlib.Path(__file__).parents[1] / "render_api_reference.py")
+ )
+
+
+def test_member_reader_keeps_public_facade_and_drops_generated_types(
+ tmp_path: pathlib.Path,
+) -> None:
+ """Compiler-generated documentation must not become package API docs."""
+ documentation = tmp_path / "LibTmux.xml"
+ documentation.write_text(
+ """
+
+
+
+ Configures the preview.
+
+
+ Gets the data directory.
+
+
+ Must not expose an internal member of a public type.
+
+
+ Must not be published.
+
+
+ Must not be published.
+
+
+
+""",
+ encoding="utf-8",
+ )
+ read_members = load_renderer()["read_members"]
+
+ members = read_members(
+ documentation,
+ frozenset(
+ {
+ "T:LibTmux.PsmuxConnectionOptions",
+ "P:LibTmux.PsmuxConnectionOptions.DataDirectory",
+ }
+ ),
+ )
+
+ assert members == {
+ "T:LibTmux.PsmuxConnectionOptions": "Configures the preview.",
+ "P:LibTmux.PsmuxConnectionOptions.DataDirectory": "Gets the data directory.",
+ }
+
+
+def test_contract_reader_excludes_internal_types(tmp_path: pathlib.Path) -> None:
+ """The review contract may describe internals without publishing them."""
+ contract = tmp_path / "public-api.json"
+ contract.write_text(
+ """{
+ "types": [
+ {
+ "id": "T:LibTmux.PsmuxConnectionOptions",
+ "package": "LibTmux",
+ "modifiers": ["public", "sealed"]
+ },
+ {
+ "id": "T:LibTmux.Internal.HiddenType",
+ "package": "LibTmux",
+ "modifiers": ["internal", "sealed"]
+ },
+ {
+ "id": "T:LibTmux.Query.Json.QueryJson",
+ "package": "LibTmux.Query.Json",
+ "modifiers": ["public", "static"]
+ }
+ ],
+ "members": [
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "package": "LibTmux",
+ "visibility": "public"
+ },
+ {
+ "id": "M:LibTmux.PsmuxConnectionOptions.ValidateInternalState",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "package": "LibTmux",
+ "visibility": "internal"
+ },
+ {
+ "id": "M:LibTmux.Internal.HiddenType.Escape",
+ "declaringType": "T:LibTmux.Internal.HiddenType",
+ "package": "LibTmux",
+ "visibility": "public"
+ }
+ ]
+}
+""",
+ encoding="utf-8",
+ )
+ public_type_names = load_renderer()["public_type_names"]
+
+ assert public_type_names(contract) == frozenset({"LibTmux.PsmuxConnectionOptions"})
+
+
+
+def test_contract_visibility_excludes_internal_members_of_public_types(
+ tmp_path: pathlib.Path,
+) -> None:
+ """A documented helper is not public merely because its type is public."""
+ documentation = tmp_path / "LibTmux.xml"
+ documentation.write_text(
+ """
+
+
+
+ Configures the preview.
+
+
+ Gets the data directory.
+
+
+ Must not expose an internal member.
+
+
+
+""",
+ encoding="utf-8",
+ )
+ contract = tmp_path / "public-api.json"
+ contract.write_text(
+ """{
+ "types": [
+ {
+ "id": "T:LibTmux.PsmuxConnectionOptions",
+ "package": "LibTmux",
+ "modifiers": ["public", "sealed"]
+ }
+ ],
+ "members": [
+ {
+ "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "DataDirectory",
+ "kind": "property",
+ "package": "LibTmux",
+ "visibility": "public"
+ },
+ {
+ "id": "M:LibTmux.PsmuxConnectionOptions.ValidateInternalState()",
+ "declaringType": "T:LibTmux.PsmuxConnectionOptions",
+ "name": "ValidateInternalState",
+ "kind": "method",
+ "parameters": [],
+ "package": "LibTmux",
+ "visibility": "internal"
+ }
+ ]
+}
+""",
+ encoding="utf-8",
+ )
+ renderer = load_renderer()
+
+ approved = renderer["public_member_ids"](documentation, contract)
+ members = renderer["read_members"](documentation, approved)
+
+ assert members == {
+ "T:LibTmux.PsmuxConnectionOptions": "Configures the preview.",
+ "P:LibTmux.PsmuxConnectionOptions.DataDirectory": "Gets the data directory.",
+ }
+
+
+def test_contract_mapping_fails_closed_on_an_unapproved_overload(
+ tmp_path: pathlib.Path,
+) -> None:
+ """Name-and-arity collisions must stop generation instead of leaking a helper."""
+ documentation = tmp_path / "LibTmux.xml"
+ documentation.write_text(
+ """
+
+
+
+ Reads by name.
+
+
+ Internal numeric helper.
+
+
+
+""",
+ encoding="utf-8",
+ )
+ contract = tmp_path / "public-api.json"
+ contract.write_text(
+ """{
+ "types": [
+ {
+ "id": "T:LibTmux.PsmuxServer",
+ "package": "LibTmux",
+ "modifiers": ["public", "sealed"]
+ }
+ ],
+ "members": [
+ {
+ "id": "M:LibTmux.PsmuxServer.Read(string)",
+ "declaringType": "T:LibTmux.PsmuxServer",
+ "name": "Read",
+ "kind": "method",
+ "parameters": [{"name": "name", "type": "string"}],
+ "package": "LibTmux",
+ "visibility": "public"
+ }
+ ]
+}
+""",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ValueError, match="more members than the approved public shape"):
+ load_renderer()["public_member_ids"](documentation, contract)
+
+
+def test_member_reader_uses_one_canonical_type_summary(tmp_path: pathlib.Path) -> None:
+ """A canonical partial-type summary is the summary readers receive."""
+ documentation = tmp_path / "LibTmux.xml"
+ documentation.write_text(
+ """
+
+
+
+ Represents an immutable server handle and snapshot.
+
+
+
+""",
+ encoding="utf-8",
+ )
+ read_members = load_renderer()["read_members"]
+
+ members = read_members(documentation, frozenset({"T:LibTmux.Server"}))
+
+ assert members == {
+ "T:LibTmux.Server": "Represents an immutable server handle and snapshot."
+ }
+
+
+def test_renderer_preserves_generic_metadata_names_as_code() -> None:
+ """Generic arity markers must not terminate their Markdown code span."""
+ render = load_renderer()["render"]
+
+ rendered = render(
+ {"T:LibTmux.CapturedRelation`1": "Holds captured children."}
+ )
+
+ assert "| ``LibTmux.CapturedRelation`1`` | Holds captured children. |" in rendered
+
+ rendered = render(
+ {"M:LibTmux.Query.QueryExtensions.Compile``1": "Compiles a query."}
+ )
+
+ assert "| ```LibTmux.Query.QueryExtensions.Compile``1``` | Compiles a query. |" in rendered
diff --git a/eng/parity/inspect_packages.py b/eng/parity/inspect_packages.py
index e486c82..0c81e88 100644
--- a/eng/parity/inspect_packages.py
+++ b/eng/parity/inspect_packages.py
@@ -41,17 +41,26 @@ class Contract:
tool : bool
Whether this packs as a .NET tool, which carries its binaries and
their symbols under ``tools/`` rather than ``lib/``.
+ dependency_versions : tuple[tuple[str, str, str], ...]
+ Exact framework, package, and minimum versions the public dependency
+ contract intentionally fixes.
"""
dependencies: frozenset[str]
tool: bool = False
+ dependency_versions: tuple[tuple[str, str, str], ...] = ()
#: Logging abstractions are interfaces with no implementation attached, so a
#: caller who wants no logging still pays nothing for it. Every other entry
#: names exactly what that package exists to add.
CONTRACTS = {
- "LibTmux": Contract(frozenset({"Microsoft.Extensions.Logging.Abstractions"})),
+ "LibTmux": Contract(
+ frozenset({"Microsoft.Extensions.Logging.Abstractions"}),
+ dependency_versions=(
+ ("net8.0", "Microsoft.Extensions.Logging.Abstractions", "8.0.0"),
+ ),
+ ),
"LibTmux.Query.Json": Contract(frozenset({"LibTmux"})),
"LibTmux.Workspace": Contract(frozenset({"LibTmux", "YamlDotNet"})),
"LibTmux.Mcp": Contract(frozenset(), tool=True),
@@ -176,6 +185,27 @@ def inspect(package: pathlib.Path) -> list[str]:
for dependency in root.iter(f"{namespace}dependency")
if dependency.attrib.get("id") not in contract.dependencies
)
+ groups = {
+ group.attrib.get("targetFramework"): group
+ for group in root.iter(f"{namespace}group")
+ }
+ for framework, dependency_id, expected in contract.dependency_versions:
+ group = groups.get(framework)
+ matching = (
+ []
+ if group is None
+ else [
+ dependency
+ for dependency in group.findall(f"{namespace}dependency")
+ if dependency.attrib.get("id") == dependency_id
+ ]
+ )
+ if len(matching) != 1 or matching[0].attrib.get("version") != expected:
+ actual = "missing" if len(matching) != 1 else matching[0].attrib.get("version")
+ violations.append(
+ f"{identifier} requires {dependency_id} {actual} for {framework}; "
+ f"expected {expected}"
+ )
# A package page that says nothing about what the package is, who wrote it,
# or where it came from is one a reader has to leave to evaluate.
diff --git a/eng/parity/tests/test_packages.py b/eng/parity/tests/test_packages.py
index 32a0873..b9bf974 100644
--- a/eng/parity/tests/test_packages.py
+++ b/eng/parity/tests/test_packages.py
@@ -28,7 +28,10 @@
"""
-def dependency_group(dependency: str | None) -> str:
+def dependency_group(
+ dependency: str | None,
+ dependency_version: str = "8.0.0",
+) -> str:
"""Return the dependency block a package with that dependency carries.
A tool bundles what it needs under ``tools/`` and declares nothing, so the
@@ -39,7 +42,7 @@ def dependency_group(dependency: str | None) -> str:
return (
" \n"
' \n'
- f' \n'
+ f' \n'
" \n"
" \n"
)
@@ -141,6 +144,37 @@ def test_an_extra_dependency_is_reported(tmp_path: pathlib.Path) -> None:
assert "LibTmux declares dependency Newtonsoft.Json" in inspect(package)
+def test_the_net8_logging_floor_cannot_be_bumped_to_net10(
+ tmp_path: pathlib.Path,
+) -> None:
+ """Dependency automation must not undo the net8 consumer contract."""
+ package = tmp_path / "LibTmux.1.0.0.nupkg"
+ with zipfile.ZipFile(package, "w") as archive:
+ archive.writestr("icon.png", "png")
+ archive.writestr(
+ "LibTmux.nuspec",
+ SPECIFICATION.format(
+ identifier="LibTmux",
+ dependencies=dependency_group(
+ "Microsoft.Extensions.Logging.Abstractions",
+ "10.0.11",
+ ),
+ project_url=PROJECT_URL,
+ repository_url=PROJECT_URL,
+ commit="a" * 40,
+ ),
+ )
+ for framework in ("net8.0", "net10.0"):
+ archive.writestr(f"lib/{framework}/LibTmux.dll", "assembly")
+ archive.writestr(f"lib/{framework}/LibTmux.xml", "")
+ (tmp_path / "LibTmux.1.0.0.snupkg").write_bytes(b"symbols")
+
+ assert (
+ "LibTmux requires Microsoft.Extensions.Logging.Abstractions 10.0.11 "
+ "for net8.0; expected 8.0.0"
+ ) in inspect(package)
+
+
def test_missing_symbols_are_reported(tmp_path: pathlib.Path) -> None:
"""Stepping into the library while debugging needs the symbols."""
package = build(tmp_path, symbols=False)
diff --git a/eng/parity/tests/test_production_plan.py b/eng/parity/tests/test_production_plan.py
index 66c512a..225425a 100644
--- a/eng/parity/tests/test_production_plan.py
+++ b/eng/parity/tests/test_production_plan.py
@@ -338,6 +338,7 @@
"T:LibTmux.TmuxChaining",
"T:LibTmux.TmuxCommand",
"T:LibTmux.TmuxEvent",
+ "T:LibTmux.TmuxEventsDroppedEvent",
"T:LibTmux.TmuxExitEvent",
"T:LibTmux.TmuxNotificationEvent",
"T:LibTmux.TmuxOutputEvent",
@@ -349,6 +350,12 @@
),
2: (
"T:LibTmux.PaneId",
+ "T:LibTmux.PsmuxCaptureOptions",
+ "T:LibTmux.PsmuxConnectionOptions",
+ "T:LibTmux.PsmuxPane",
+ "T:LibTmux.PsmuxServer",
+ "T:LibTmux.PsmuxSession",
+ "T:LibTmux.PsmuxWindow",
"T:LibTmux.ServerConnectionOptions",
"T:LibTmux.ServerGeneration",
"T:LibTmux.SessionId",
diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py
index c639870..af56f23 100644
--- a/eng/parity/tests/test_public_api.py
+++ b/eng/parity/tests/test_public_api.py
@@ -13,9 +13,10 @@
TMUX_VERSION_CONTRACT: dict[str, t.Any] = {
"grammar": [
- "version = next / release / prerelease",
+ "version = next / release / micro / prerelease",
'next = "next-" core',
'release = core [patch] ["-openbsd"]',
+ 'micro = core "." uint',
'prerelease = core ("-rc" posint / "-dev" ["." uint])',
'core = uint "." uint',
"patch = 1*LOWER",
@@ -27,6 +28,7 @@
"majorMinor": "the two invariant-culture decimal core components",
"suffixExamples": {
"3.7": None,
+ "3.3.7": "7",
"3.7b": "b",
"3.0-rc3": "rc3",
"3.3a-openbsd": "a-openbsd",
@@ -54,7 +56,7 @@
"03.7",
"3.07",
"3.7B",
- "3.7.1",
+ "3.7.01",
"3.7-",
"+3.7",
"integer component overflow",
@@ -62,9 +64,10 @@
},
"ordering": {
"core": "major then minor, numerically ascending",
- "sameCore": "next < dev < rcN < final < letter patch",
+ "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch",
"development": "a missing dev number precedes numeric dev numbers",
"releaseCandidate": "N compares numerically",
+ "micro": "N compares numerically",
"patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27",
"vendor": (
"-openbsd immediately follows its corresponding final or patch release"
@@ -73,6 +76,7 @@
"examples": [
"next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2",
"3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b",
+ "3.3 < 3.3.1 < 3.3.10 < 3.3a",
"3.7b < next-3.8 < 3.8",
],
"invalidOperands": (
@@ -371,16 +375,31 @@ def test_io_is_async_and_cancellation_is_last() -> None:
def test_process_api_is_windows_annotated_but_portable_api_is_not() -> None:
- """Mark process entry points without contaminating portable values."""
+ """Keep tmux annotated while the cross-platform psmux facade stays portable."""
public_api = load_json(csharp_docs_root() / "public-api.json")
members = public_api["members"]
for member in members:
annotations = member.get("platformAnnotations", [])
if member.get("processBacked"):
- assert annotations == ['UnsupportedOSPlatform("windows")']
+ if member["declaringType"].startswith("T:LibTmux.Psmux"):
+ assert member["portable"] is True
+ assert not annotations
+ else:
+ assert annotations == ['UnsupportedOSPlatform("windows")']
if member.get("portable"):
assert not annotations
+ control = next(
+ member
+ for member in members
+ if member["id"]
+ == "M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)"
+ )
+ assert control["performsIO"] is True
+ assert control["processBacked"] is True
+ assert control["portable"] is False
+ assert control["platformAnnotations"] == ['UnsupportedOSPlatform("windows")']
+
def test_entities_are_immutable_handles_not_destructive_disposables() -> None:
"""Reserve asynchronous disposal for explicitly owned resources."""
diff --git a/eng/parity/tests/test_workflows.py b/eng/parity/tests/test_workflows.py
index 5c5d3b8..79b02bd 100644
--- a/eng/parity/tests/test_workflows.py
+++ b/eng/parity/tests/test_workflows.py
@@ -26,6 +26,8 @@ def verify(root: pathlib.Path) -> list[str]:
BUILD = """
+on:
+ workflow_call:
jobs:
build:
steps:
@@ -40,10 +42,14 @@ def verify(root: pathlib.Path) -> list[str]:
- run: dotnet run --project tests/LibTmux.PackageConsumer
- run: dotnet run --project examples/LibTmux.Examples
- run: dotnet test --project tests/LibTmux.ExampleTests
+ - run: uv run python eng/docs/render_api_reference.py --check
+ - run: uv run python eng/parity/render_public_api.py --check
- run: uv run python eng/docs/sync_snippets.py --check
"""
MATRIX = """
+on:
+ workflow_call:
jobs:
matrix:
strategy:
@@ -57,13 +63,48 @@ def verify(root: pathlib.Path) -> list[str]:
run: dotnet test
"""
+RELEASE = """
+jobs:
+ dotnet:
+ uses: ./.github/workflows/dotnet.yml
+ compatibility:
+ uses: ./.github/workflows/dotnet-tmux.yml
+ psmux:
+ runs-on: [self-hosted, Windows, X64, psmux]
+ steps:
+ - run: |
+ $env:NUGET_PACKAGES = 'fresh'
+ dotnet restore LibTmux.slnx
+ - env:
+ ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }}
+ SOURCE_URL: ${{ vars.PSMUX_SOURCE_PROVENANCE_URL }}
+ LICENSE_URL: ${{ vars.PSMUX_LICENSE_URL }}
+ WSL_DISTRIBUTION: ${{ vars.PSMUX_WSL_DISTRIBUTION }}
+ WSL_DOTNET_PATH: ${{ vars.PSMUX_WSL_DOTNET_PATH }}
+ run: Invoke-PsmuxSmoke.ps1 -RunWslSmoke -WslDotnetPath /dotnet -WslRepository $env:GITHUB_WORKSPACE 1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e
+ - run: echo 'net8.0' 'net10.0'
+ publish:
+ needs: [dotnet, compatibility, psmux]
+ steps:
+ - uses: actions/download-artifact@pinned
+ - env:
+ REF_TYPE: ${{ github.ref_type }}
+ run: dotnet nuget push package.nupkg
+"""
+
-def write(root: pathlib.Path, build: str, matrix: str) -> pathlib.Path:
- """Lay out a repository holding the two workflows."""
+def write(
+ root: pathlib.Path,
+ build: str,
+ matrix: str,
+ release: str = RELEASE,
+) -> pathlib.Path:
+ """Lay out a repository holding the release's workflow set."""
workflows = root / ".github" / "workflows"
workflows.mkdir(parents=True)
(workflows / "dotnet.yml").write_text(build, encoding="utf-8")
(workflows / "dotnet-tmux.yml").write_text(matrix, encoding="utf-8")
+ (workflows / "release.yml").write_text(release, encoding="utf-8")
return root
@@ -115,6 +156,8 @@ def test_skipped_integration_tests_are_reported(tmp_path: pathlib.Path) -> None:
"dotnet pack",
"LibTmux.PackageConsumer",
"LibTmux.ExampleTests",
+ "render_api_reference.py --check",
+ "render_public_api.py --check",
"sync_snippets.py --check",
],
)
@@ -135,3 +178,86 @@ def test_a_missing_workflow_is_reported(tmp_path: pathlib.Path) -> None:
(root / ".github" / "workflows" / "dotnet-tmux.yml").unlink()
assert verify(root) == ["missing workflow: dotnet-tmux.yml"]
+
+
+@pytest.mark.parametrize(
+ "step",
+ [
+ "uses: ./.github/workflows/dotnet.yml",
+ "uses: ./.github/workflows/dotnet-tmux.yml",
+ "needs: [dotnet, compatibility, psmux]",
+ "github.ref_type",
+ "actions/download-artifact@",
+ "PSMUX_ARTIFACT_URL",
+ "PSMUX_SOURCE_PROVENANCE_URL",
+ "PSMUX_LICENSE_URL",
+ "PSMUX_WSL_DISTRIBUTION",
+ "PSMUX_WSL_DOTNET_PATH",
+ "runs-on: [self-hosted, Windows, X64, psmux]",
+ "Invoke-PsmuxSmoke.ps1",
+ "-RunWslSmoke",
+ "-WslDotnetPath",
+ "-WslRepository $env:GITHUB_WORKSPACE",
+ ],
+)
+def test_a_dropped_release_gate_is_reported(
+ tmp_path: pathlib.Path,
+ step: str,
+) -> None:
+ """Publishing must consume every same-commit and psmux proof."""
+ root = write(
+ tmp_path,
+ BUILD,
+ MATRIX.format(versions=every_version()),
+ RELEASE.replace(step, "echo skipped"),
+ )
+
+ assert f"release.yml omits {step}" in verify(root)
+
+
+def test_release_cannot_hide_an_existing_version(tmp_path: pathlib.Path) -> None:
+ """NuGet's immutable duplicate must fail rather than look published."""
+ root = write(
+ tmp_path,
+ BUILD,
+ MATRIX.format(versions=every_version()),
+ RELEASE + "\n# --skip-duplicate\n",
+ )
+
+ assert "release.yml can hide an existing immutable package version" in verify(root)
+
+
+def test_release_seeds_its_fresh_cache_before_the_solution_restore(
+ tmp_path: pathlib.Path,
+) -> None:
+ """The packed consumer needs external dependencies in its isolated cache."""
+ root = write(
+ tmp_path,
+ BUILD,
+ MATRIX.format(versions=every_version()),
+ RELEASE.replace(
+ "$env:NUGET_PACKAGES = 'fresh'\n dotnet restore LibTmux.slnx",
+ "dotnet restore LibTmux.slnx\n $env:NUGET_PACKAGES = 'fresh'",
+ ),
+ )
+
+ assert (
+ "release.yml isolates NuGet only after restoring dependencies" in verify(root)
+ )
+
+
+@pytest.mark.parametrize("name", ["dotnet.yml", "dotnet-tmux.yml"])
+def test_release_gate_workflows_must_be_callable(
+ tmp_path: pathlib.Path,
+ name: str,
+) -> None:
+ """A same-commit release call needs a workflow_call entry point."""
+ build = BUILD if name != "dotnet.yml" else BUILD.replace("workflow_call:", "manual:")
+ matrix = (
+ MATRIX.format(versions=every_version())
+ if name != "dotnet-tmux.yml"
+ else MATRIX.format(versions=every_version()).replace("workflow_call:", "manual:")
+ )
+ root = write(tmp_path, build, matrix)
+
+ assert f"{name} cannot be called by release.yml" in verify(root)
diff --git a/eng/parity/verify_production_plan.py b/eng/parity/verify_production_plan.py
index 0fa3524..6bcee71 100644
--- a/eng/parity/verify_production_plan.py
+++ b/eng/parity/verify_production_plan.py
@@ -351,6 +351,7 @@
"T:LibTmux.TmuxChaining",
"T:LibTmux.TmuxCommand",
"T:LibTmux.TmuxEvent",
+ "T:LibTmux.TmuxEventsDroppedEvent",
"T:LibTmux.TmuxExitEvent",
"T:LibTmux.TmuxNotificationEvent",
"T:LibTmux.TmuxOutputEvent",
@@ -362,6 +363,12 @@
),
2: (
"T:LibTmux.PaneId",
+ "T:LibTmux.PsmuxCaptureOptions",
+ "T:LibTmux.PsmuxConnectionOptions",
+ "T:LibTmux.PsmuxPane",
+ "T:LibTmux.PsmuxServer",
+ "T:LibTmux.PsmuxSession",
+ "T:LibTmux.PsmuxWindow",
"T:LibTmux.ServerConnectionOptions",
"T:LibTmux.ServerGeneration",
"T:LibTmux.SessionId",
diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py
index 4605161..5283adc 100644
--- a/eng/parity/verify_public_api.py
+++ b/eng/parity/verify_public_api.py
@@ -57,9 +57,10 @@
}
TMUX_VERSION_CONTRACT: dict[str, t.Any] = {
"grammar": [
- "version = next / release / prerelease",
+ "version = next / release / micro / prerelease",
'next = "next-" core',
'release = core [patch] ["-openbsd"]',
+ 'micro = core "." uint',
'prerelease = core ("-rc" posint / "-dev" ["." uint])',
'core = uint "." uint',
"patch = 1*LOWER",
@@ -71,6 +72,7 @@
"majorMinor": "the two invariant-culture decimal core components",
"suffixExamples": {
"3.7": None,
+ "3.3.7": "7",
"3.7b": "b",
"3.0-rc3": "rc3",
"3.3a-openbsd": "a-openbsd",
@@ -98,7 +100,7 @@
"03.7",
"3.07",
"3.7B",
- "3.7.1",
+ "3.7.01",
"3.7-",
"+3.7",
"integer component overflow",
@@ -106,9 +108,10 @@
},
"ordering": {
"core": "major then minor, numerically ascending",
- "sameCore": "next < dev < rcN < final < letter patch",
+ "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch",
"development": "a missing dev number precedes numeric dev numbers",
"releaseCandidate": "N compares numerically",
+ "micro": "N compares numerically",
"patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27",
"vendor": (
"-openbsd immediately follows its corresponding final or patch release"
@@ -117,6 +120,7 @@
"examples": [
"next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2",
"3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b",
+ "3.3 < 3.3.1 < 3.3.10 < 3.3a",
"3.7b < next-3.8 < 3.8",
],
"invalidOperands": (
@@ -924,10 +928,15 @@ def validate_async_and_platform(
):
violations.append(f"invalid cancellation parameter: {member_id}")
annotations = member.get("platformAnnotations", [])
- if member.get("processBacked") and annotations != [
- 'UnsupportedOSPlatform("windows")'
- ]:
- violations.append(f"missing Windows annotation: {member_id}")
+ psmux_facade = str(member.get("declaringType", "")).startswith(
+ "T:LibTmux.Psmux"
+ )
+ if member.get("processBacked"):
+ if psmux_facade:
+ if annotations or member.get("portable") is not True:
+ violations.append(f"invalid psmux platform contract: {member_id}")
+ elif annotations != ['UnsupportedOSPlatform("windows")']:
+ violations.append(f"missing Windows annotation: {member_id}")
if member.get("portable") and annotations:
violations.append(f"portable member has platform annotation: {member_id}")
diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py
index 6f29a0f..49930c8 100644
--- a/eng/parity/verify_workflows.py
+++ b/eng/parity/verify_workflows.py
@@ -26,10 +26,31 @@
"LibTmux.PackageConsumer",
"LibTmux.Examples",
"LibTmux.ExampleTests",
+ "render_api_reference.py --check",
+ "render_public_api.py --check",
"sync_snippets.py --check",
"fetch-depth: 0",
)
+REQUIRED_RELEASE_STEPS = (
+ "uses: ./.github/workflows/dotnet.yml",
+ "uses: ./.github/workflows/dotnet-tmux.yml",
+ "needs: [dotnet, compatibility, psmux]",
+ "github.ref_type",
+ "actions/download-artifact@",
+ "PSMUX_ARTIFACT_URL",
+ "PSMUX_SOURCE_PROVENANCE_URL",
+ "PSMUX_LICENSE_URL",
+ "PSMUX_WSL_DISTRIBUTION",
+ "PSMUX_WSL_DOTNET_PATH",
+ "runs-on: [self-hosted, Windows, X64, psmux]",
+ "Invoke-PsmuxSmoke.ps1",
+ "-RunWslSmoke",
+ "-WslDotnetPath",
+ "-WslRepository $env:GITHUB_WORKSPACE",
+ "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e",
+)
+
def verify(root: pathlib.Path) -> list[str]:
"""Return one message per way the workflows fall short."""
@@ -38,9 +59,10 @@ def verify(root: pathlib.Path) -> list[str]:
build = workflows / "dotnet.yml"
matrix = workflows / "dotnet-tmux.yml"
+ release = workflows / "release.yml"
violations.extend(
f"missing workflow: {path.name}"
- for path in (build, matrix)
+ for path in (build, matrix, release)
if not path.is_file()
)
@@ -49,6 +71,7 @@ def verify(root: pathlib.Path) -> list[str]:
build_text = build.read_text(encoding="utf-8")
matrix_text = matrix.read_text(encoding="utf-8")
+ release_text = release.read_text(encoding="utf-8")
violations.extend(
f"dotnet.yml omits {step}"
@@ -65,6 +88,27 @@ def verify(root: pathlib.Path) -> list[str]:
for framework in TARGET_FRAMEWORKS
if f"'{framework}'" not in matrix_text
)
+ violations.extend(
+ f"release.yml omits {step}"
+ for step in REQUIRED_RELEASE_STEPS
+ if step not in release_text
+ )
+
+ for name, content in (("dotnet.yml", build_text), ("dotnet-tmux.yml", matrix_text)):
+ if "workflow_call:" not in content:
+ violations.append(f"{name} cannot be called by release.yml")
+
+ if "--skip-duplicate" in release_text:
+ violations.append("release.yml can hide an existing immutable package version")
+
+ cache = release_text.find("$env:NUGET_PACKAGES")
+ restore = release_text.find("dotnet restore LibTmux.slnx")
+ if cache < 0 or restore < 0 or cache > restore:
+ violations.append("release.yml isolates NuGet only after restoring dependencies")
+
+ for framework in TARGET_FRAMEWORKS:
+ if f"'{framework}'" not in release_text:
+ violations.append(f"release.yml omits psmux {framework}")
# One lane failing says something about that tmux version, which is only
# readable when the other lanes still run.
diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1
new file mode 100644
index 0000000..8b4993b
--- /dev/null
+++ b/eng/psmux/Invoke-PsmuxSmoke.ps1
@@ -0,0 +1,1098 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)]
+ [string] $PsmuxPath,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^[0-9A-Fa-f]{64}$')]
+ [string] $ExpectedSha256,
+
+ [Parameter(Mandatory)]
+ [string] $DataDirectory,
+
+ [Parameter(Mandatory)]
+ [ValidatePattern('^(?!default$)(?!.*__)[a-z0-9_-]{16,64}$')]
+ [string] $NamespaceName,
+
+ [Parameter(Mandatory)]
+ [string] $DotnetPath,
+
+ [Parameter(Mandatory)]
+ [string] $TestAssembly,
+
+ [Parameter(Mandatory)]
+ [string] $ExampleAssembly,
+
+ [Parameter(Mandatory)]
+ [string] $PackageConsumerAssembly,
+
+ [Parameter(Mandatory)]
+ [ValidateSet('net8.0', 'net10.0')]
+ [string] $TargetFramework,
+
+ [string] $WslDistribution,
+
+ [string] $WslRepository,
+
+ [string] $WslDotnetPath,
+
+ [switch] $RunWslSmoke,
+
+ [ValidatePattern('^(?!.*__)[A-Za-z0-9_-]+$')]
+ [string] $SessionName = 'smoke'
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$savedOutputEncoding = [Console]::OutputEncoding
+$supportedSha256 = '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e'
+$wslTimeoutScript = @'
+case "$(LC_ALL=C /usr/bin/timeout --version 2>/dev/null)" in
+ 'timeout (GNU coreutils) '*) ;;
+ *) exit 125 ;;
+esac
+deadline=$1
+shift
+exec /usr/bin/timeout --signal=KILL "$deadline" "$@"
+'@
+$wslDotnetValidationScript = @'
+set -eu
+candidate=$1
+framework_major=$2
+case "$candidate" in
+ /*) ;;
+ *) exit 126 ;;
+esac
+resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126
+case "$resolved" in
+ /*) ;;
+ *) exit 126 ;;
+esac
+[ -f "$resolved" ] && [ -x "$resolved" ] || exit 126
+"$resolved" --info >/dev/null || exit 126
+runtimes=$("$resolved" --list-runtimes) || exit 126
+case "
+$runtimes
+" in
+ *"
+Microsoft.NETCore.App $framework_major."*) ;;
+ *)
+ printf '%s\n' \
+ "required Microsoft.NETCore.App $framework_major runtime is unavailable" >&2
+ exit 126
+ ;;
+esac
+printf '%s\n' "$resolved"
+'@
+$wslPathResolutionScript = @'
+set -eu
+mode=$1
+requirement=$2
+candidate=$3
+case "$mode" in
+ windows)
+ candidate=$(/usr/bin/wslpath -u -- "$candidate") || exit 126
+ ;;
+ linux) ;;
+ *) exit 126 ;;
+esac
+case "$candidate" in
+ /*) ;;
+ *) exit 126 ;;
+esac
+if [ "$requirement" = directory ]; then
+ resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126
+ case "$resolved" in
+ /*) ;;
+ *) exit 126 ;;
+ esac
+ [ -d "$resolved" ] || exit 126
+ candidate=$resolved
+elif [ "$requirement" != path ]; then
+ exit 126
+fi
+printf '%s\n' "$candidate"
+'@
+if ($ExpectedSha256 -ine $supportedSha256) {
+ throw 'ExpectedSha256 must match the exact audited psmux client build.'
+}
+$ExpectedSha256 = $supportedSha256
+
+function Get-IsolatedDataDirectory([string] $Path) {
+ if ($Path -notmatch '^[A-Za-z]:\\') {
+ throw 'DataDirectory must be an absolute path on a local Windows drive.'
+ }
+
+ $full = [IO.Path]::GetFullPath($Path)
+ $root = [IO.Path]::GetPathRoot($full)
+ $drive = [IO.DriveInfo]::new($root)
+ if ($drive.DriveType -ne [IO.DriveType]::Fixed) {
+ throw 'DataDirectory must be on a fixed local Windows drive.'
+ }
+ if ([string]::Equals(
+ $full.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar)),
+ $root.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar)),
+ [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw 'DataDirectory must not be a filesystem root.'
+ }
+
+ $relative = $full.Substring($root.Length)
+ foreach ($segment in $relative.Split(
+ [char[]] @([IO.Path]::DirectorySeparatorChar),
+ [System.StringSplitOptions]::RemoveEmptyEntries)) {
+ if ($segment -eq '.' -or $segment -eq '..' -or
+ $segment.EndsWith(' ') -or $segment.EndsWith('.')) {
+ throw 'DataDirectory contains an ambiguous Windows path segment.'
+ }
+ if ($segment.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0 -or
+ $segment.ToCharArray().Where({ [char]::IsControl($_) }).Count -gt 0) {
+ throw 'DataDirectory contains an invalid Windows path segment.'
+ }
+
+ $device = $segment.Split('.')[0]
+ if ($device -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
+ throw 'DataDirectory contains a reserved Windows device name.'
+ }
+ }
+
+ return $full.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar))
+}
+
+function Assert-OnePassingTest([string] $ResultPath, [string] $Leg) {
+ if (-not (Test-Path -LiteralPath $ResultPath -PathType Leaf)) {
+ throw "$Leg did not write an xUnit result file."
+ }
+
+ [xml] $result = Get-Content -LiteralPath $ResultPath -Raw
+ $assemblies = @($result.assemblies.assembly)
+ if ($assemblies.Count -ne 1 -or
+ [int] $assemblies[0].total -ne 1 -or
+ [int] $assemblies[0].passed -ne 1 -or
+ [int] $assemblies[0].failed -ne 0 -or
+ [int] $assemblies[0].skipped -ne 0 -or
+ [int] $assemblies[0].'not-run' -ne 0 -or
+ [int] $assemblies[0].errors -ne 0) {
+ throw "$Leg did not run exactly one passing, non-skipped psmux smoke test."
+ }
+}
+
+function Get-BoundedNativeErrorDetail([string] $ErrorText) {
+ if ([string]::IsNullOrWhiteSpace($ErrorText)) {
+ return
+ }
+
+ $detail = [regex]::Replace(
+ $ErrorText.Trim(),
+ '[\p{Cc}\p{Cf}]+',
+ ' ')
+ $detail = [regex]::Replace($detail, '\s+', ' ').Trim()
+ if ($detail.Length -eq 0) {
+ return
+ }
+ if ($detail.Length -gt 512) {
+ $detail = '...' + $detail.Substring($detail.Length - 509)
+ }
+ return $detail
+}
+
+function Get-NativeExitMessage(
+ [string] $Leg,
+ [int] $ExitCode,
+ [string] $ErrorText) {
+ $message = "$Leg exited $ExitCode."
+ $detail = Get-BoundedNativeErrorDetail $ErrorText
+ if (-not $detail) {
+ return $message
+ }
+ return "$message stderr: $detail"
+}
+
+function Assert-QueryProgram(
+ [string[]] $Output,
+ [int] $ExitCode,
+ [string] $ErrorText,
+ [string] $ExpectedText,
+ [string] $Leg) {
+ if ($ExitCode -ne 0) {
+ throw (Get-NativeExitMessage $Leg $ExitCode $ErrorText)
+ }
+ if ($Output.Where({ $_.Contains($ExpectedText) }).Count -eq 0) {
+ throw "$Leg did not report the UTF-8 fixture text."
+ }
+}
+
+function ConvertTo-NativeArgument([string] $Argument) {
+ if ($null -eq $Argument) {
+ throw 'Native command arguments must not be null.'
+ }
+ if ($Argument.Length -gt 0 -and $Argument -notmatch '[\s"]') {
+ return $Argument
+ }
+
+ $quoted = [Text.StringBuilder]::new()
+ $null = $quoted.Append('"')
+ $backslashes = 0
+ foreach ($character in $Argument.ToCharArray()) {
+ if ($character -eq [char] 0x5c) {
+ $backslashes++
+ continue
+ }
+ if ($character -eq [char] 0x22) {
+ $null = $quoted.Append([char] 0x5c, (2 * $backslashes) + 1)
+ $null = $quoted.Append($character)
+ $backslashes = 0
+ continue
+ }
+ if ($backslashes -gt 0) {
+ $null = $quoted.Append([char] 0x5c, $backslashes)
+ $backslashes = 0
+ }
+ $null = $quoted.Append($character)
+ }
+ if ($backslashes -gt 0) {
+ $null = $quoted.Append([char] 0x5c, 2 * $backslashes)
+ }
+ $null = $quoted.Append('"')
+ return $quoted.ToString()
+}
+
+function ConvertFrom-NativeOutput([string] $Text) {
+ if ([string]::IsNullOrEmpty($Text)) {
+ return
+ }
+
+ $normalized = $Text.Replace("`r`n", "`n").Replace("`r", "`n")
+ if ($normalized.EndsWith("`n")) {
+ $normalized = $normalized.Substring(0, $normalized.Length - 1)
+ }
+ if ($normalized.Length -gt 0) {
+ $normalized.Split([char] 0x0a)
+ }
+}
+
+function ConvertFrom-Utf8Bytes(
+ [byte[]] $Bytes,
+ [string] $Leg,
+ [string] $StreamName) {
+ $utf8 = [Text.UTF8Encoding]::new($false, $true)
+ try {
+ return $utf8.GetString($Bytes)
+ }
+ catch [Text.DecoderFallbackException] {
+ throw [IO.InvalidDataException]::new(
+ "$Leg emitted invalid UTF-8 on $StreamName.",
+ $_.Exception)
+ }
+}
+
+function Invoke-CapturedNative(
+ [string] $FilePath,
+ [string[]] $ArgumentList,
+ [string] $CaptureDirectory,
+ [int] $TimeoutSeconds,
+ [string] $Leg,
+ [bool] $KillDescendantsOnTimeout = $false) {
+ if ($TimeoutSeconds -le 0) {
+ throw 'Native command timeouts must be positive.'
+ }
+
+ $commandLine = [string]::Join(
+ ' ',
+ @($ArgumentList | ForEach-Object { ConvertTo-NativeArgument $_ }))
+ $startInfo = [Diagnostics.ProcessStartInfo]::new()
+ $startInfo.FileName = $FilePath
+ $startInfo.Arguments = $commandLine
+ $startInfo.WorkingDirectory = $CaptureDirectory
+ $startInfo.UseShellExecute = $false
+ $startInfo.RedirectStandardOutput = $true
+ $startInfo.RedirectStandardError = $true
+ $startInfo.CreateNoWindow = $true
+ $process = [Diagnostics.Process]::new()
+ $process.StartInfo = $startInfo
+ $stdoutBuffer = [IO.MemoryStream]::new()
+ $stderrBuffer = [IO.MemoryStream]::new()
+ $timedOut = $false
+ try {
+ if (-not $process.Start()) {
+ throw "$Leg could not start."
+ }
+ $stdoutTask = $process.StandardOutput.BaseStream.CopyToAsync($stdoutBuffer)
+ $stderrTask = $process.StandardError.BaseStream.CopyToAsync($stderrBuffer)
+ if (-not $process.WaitForExit($TimeoutSeconds * 1000)) {
+ $timedOut = $true
+ if (-not $process.HasExited) {
+ if ($KillDescendantsOnTimeout) {
+ $taskkill = [IO.Path]::Combine(
+ $env:SystemRoot,
+ 'System32',
+ 'taskkill.exe')
+ $treeKill = Invoke-CapturedNative `
+ $taskkill `
+ @('/PID', $process.Id.ToString(), '/T', '/F') `
+ $CaptureDirectory `
+ 10 `
+ "$Leg process-tree termination"
+ if ($treeKill.ExitCode -ne 0 -and -not $process.HasExited) {
+ throw (Get-NativeExitMessage `
+ "$Leg process-tree termination" `
+ $treeKill.ExitCode `
+ $treeKill.Error)
+ }
+ }
+ else {
+ $process.Kill()
+ }
+ }
+ if (-not $process.WaitForExit(5000)) {
+ throw "$Leg exceeded its timeout and its exact process survived termination."
+ }
+ }
+ $process.WaitForExit()
+
+ $null = $stdoutTask.GetAwaiter().GetResult()
+ $null = $stderrTask.GetAwaiter().GetResult()
+ $exitCode = $process.ExitCode
+ try {
+ $stdout = ConvertFrom-Utf8Bytes $stdoutBuffer.ToArray() $Leg 'stdout'
+ $stderr = ConvertFrom-Utf8Bytes $stderrBuffer.ToArray() $Leg 'stderr'
+ }
+ catch [IO.InvalidDataException] {
+ if ($timedOut) {
+ throw [IO.InvalidDataException]::new(
+ "$Leg exceeded its $TimeoutSeconds-second timeout. " +
+ $_.Exception.Message,
+ $_.Exception)
+ }
+ elseif ($exitCode -ne 0) {
+ throw [IO.InvalidDataException]::new(
+ "$Leg exited $exitCode. $($_.Exception.Message)",
+ $_.Exception)
+ }
+ throw
+ }
+ if ($timedOut) {
+ $timeoutMessage = "$Leg exceeded its $TimeoutSeconds-second timeout."
+ $detail = Get-BoundedNativeErrorDetail $stderr
+ if ($detail) {
+ $timeoutMessage += " stderr: $detail"
+ }
+ throw $timeoutMessage
+ }
+ return [pscustomobject] @{
+ Output = @(ConvertFrom-NativeOutput $stdout)
+ Error = $stderr
+ ExitCode = $exitCode
+ }
+ }
+ finally {
+ $process.Dispose()
+ $stdoutBuffer.Dispose()
+ $stderrBuffer.Dispose()
+ }
+}
+
+function Test-ExactProcessAlive(
+ [int] $ProcessId,
+ [long] $StartTimeUtcTicks) {
+ $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue
+ if (-not $process) {
+ return $false
+ }
+
+ return $process.StartTime.ToUniversalTime().Ticks -eq $StartTimeUtcTicks
+}
+
+function Convert-ToWslPath(
+ [string] $Path,
+ [string] $Distribution,
+ [string] $Kind,
+ [string] $CaptureDirectory) {
+ $pathMode = 'windows'
+ if ($Path.StartsWith('/')) {
+ $pathMode = 'linux'
+ }
+ $pathRequirement = 'path'
+ $operation = 'translation'
+ if ($Kind -ceq 'repository') {
+ $pathRequirement = 'directory'
+ $operation = 'resolution'
+ }
+ $translation = Invoke-CapturedNative `
+ 'wsl.exe' `
+ @(
+ '--distribution', $Distribution,
+ '--exec', '/bin/sh', '-eu', '-c',
+ $wslTimeoutScript,
+ 'libtmux-timeout', '15s',
+ '/bin/sh', '-eu', '-c',
+ $wslPathResolutionScript,
+ 'libtmux-path-resolution',
+ $pathMode, $pathRequirement, $Path) `
+ $CaptureDirectory `
+ 30 `
+ "WSL $Kind path $operation" `
+ $true
+ if ($translation.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ "WSL $Kind path $operation" `
+ $translation.ExitCode `
+ $translation.Error)
+ }
+ if ($translation.Output.Count -ne 1 -or
+ [string]::IsNullOrWhiteSpace($translation.Output[0])) {
+ throw "WSL could not translate the $Kind path."
+ }
+
+ $resolvedPath = $translation.Output[0].Trim()
+ if (-not $resolvedPath.StartsWith('/') -or
+ $resolvedPath.ToCharArray().Where(
+ { [char]::IsControl($_) }).Count -ne 0) {
+ throw "WSL returned an invalid $Kind path."
+ }
+
+ return $resolvedPath
+}
+
+$expectedBanner = @(
+ 'tmux 3.3.7'
+ 'psmux 3.3.7 (aa26cd3 2026-08-17)'
+)
+$fixtureBytes = [byte[]] (
+ 0x68, 0xc3, 0xa9, 0x6c, 0x6c, 0x6f, 0x2d, 0xe9, 0x9b, 0xaa,
+ 0x2d, 0xf0, 0x9f, 0x98, 0x80)
+$expectedText = [Text.Encoding]::UTF8.GetString($fixtureBytes)
+$fixtureByteList = [string]::Join(
+ ',',
+ @($fixtureBytes | ForEach-Object { $_.ToString() }))
+$fixtureCommand =
+ '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);' +
+ '[Console]::WriteLine([Text.Encoding]::UTF8.GetString([byte[]](' +
+ $fixtureByteList + ')))'
+$ownedEnvironment = [System.Collections.Generic.HashSet[string]]::new(
+ [System.StringComparer]::OrdinalIgnoreCase)
+foreach ($name in @(
+ 'LIBTMUX_PSMUX_BINARY'
+ 'LIBTMUX_PSMUX_EXPECTED_TEXT'
+ 'LIBTMUX_PSMUX_NAMESPACE'
+ 'LIBTMUX_PSMUX_SHA256'
+ 'LIBTMUX_PSMUX_SMOKE'
+ 'PSMUX_DATA_DIR'
+ 'PSMUX_NO_WARM'
+ 'TMUX'
+ 'TMUX_PANE'
+)) {
+ $null = $ownedEnvironment.Add($name)
+}
+foreach ($entry in [Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
+ $name = [string] $entry.Key
+ if ($name.StartsWith('PSMUX_', [System.StringComparison]::OrdinalIgnoreCase) -or
+ $name.StartsWith(
+ 'LIBTMUX_PSMUX_',
+ [System.StringComparison]::OrdinalIgnoreCase)) {
+ $null = $ownedEnvironment.Add($name)
+ }
+}
+$savedEnvironment = @{}
+$savedNames = [System.Collections.Generic.List[string]]::new()
+
+$creationAttempted = $false
+$createdIdentity = $null
+$createdPid = $null
+$createdProcessStartTicks = $null
+$createdSessionId = $null
+$dataDirectoryCreated = $false
+$exitCode = 1
+$configPath = $null
+$configCreated = $false
+$nativeResultPath = $null
+$wslResultPath = $null
+$wslRepositoryPath = $null
+$resolvedWslDotnetPath = $null
+$primaryFailure = $null
+$cleanupFailures = [System.Collections.Generic.List[System.Exception]]::new()
+try {
+ [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+ foreach ($name in $ownedEnvironment) {
+ $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
+ $savedNames.Add($name)
+ [Environment]::SetEnvironmentVariable($name, $null, 'Process')
+ }
+
+ if ($RunWslSmoke -and ([string]::IsNullOrWhiteSpace($WslDistribution) -or
+ [string]::IsNullOrWhiteSpace($WslRepository) -or
+ [string]::IsNullOrWhiteSpace($WslDotnetPath))) {
+ throw 'RunWslSmoke requires WslDistribution, WslRepository, and WslDotnetPath.'
+ }
+ if ($RunWslSmoke -and
+ ($WslDotnetPath -cne $WslDotnetPath.Trim() -or
+ -not $WslDotnetPath.StartsWith('/') -or
+ $WslDotnetPath.ToCharArray().Where(
+ { [char]::IsControl($_) }).Count -ne 0)) {
+ throw 'WslDotnetPath must be a control-free absolute Linux path.'
+ }
+ if ($RunWslSmoke -and
+ ($WslRepository -cne $WslRepository.Trim() -or
+ ($WslRepository[0] -ne '/' -and
+ $WslRepository -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+)') -or
+ $WslRepository.ToCharArray().Where(
+ { [char]::IsControl($_) }).Count -ne 0)) {
+ throw 'WslRepository must be a control-free absolute Linux or Windows path.'
+ }
+
+ foreach ($pathInput in @(
+ $PsmuxPath,
+ $DotnetPath,
+ $TestAssembly,
+ $ExampleAssembly,
+ $PackageConsumerAssembly)) {
+ if ($pathInput -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+)') {
+ throw 'Every executable and assembly path must be an absolute Windows path.'
+ }
+ }
+ if ($PsmuxPath -notmatch '^[A-Za-z]:\\') {
+ throw 'PsmuxPath must be local to a Windows drive.'
+ }
+
+ $psmuxFile = Get-Item -LiteralPath $PsmuxPath -ErrorAction Stop
+ if ($psmuxFile.PSIsContainer -or $psmuxFile.Extension -ine '.exe') {
+ throw 'PsmuxPath must identify an existing .exe file.'
+ }
+ if (($psmuxFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
+ throw 'PsmuxPath must not be a symbolic link or reparse point.'
+ }
+ $psmuxDrive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($psmuxFile.FullName))
+ if ($psmuxDrive.DriveType -ne [IO.DriveType]::Fixed) {
+ throw 'PsmuxPath must be on a fixed local Windows drive.'
+ }
+
+ $dotnetFile = Get-Item -LiteralPath $DotnetPath -ErrorAction Stop
+ if ($dotnetFile.PSIsContainer -or $dotnetFile.Extension -ine '.exe') {
+ throw 'DotnetPath must identify an existing dotnet.exe file.'
+ }
+
+ $testFile = Get-Item -LiteralPath $TestAssembly -ErrorAction Stop
+ if ($testFile.PSIsContainer -or $testFile.Extension -ine '.dll') {
+ throw 'TestAssembly must identify the built unit-test DLL.'
+ }
+ $exampleFile = Get-Item -LiteralPath $ExampleAssembly -ErrorAction Stop
+ if ($exampleFile.PSIsContainer -or $exampleFile.Extension -ine '.dll') {
+ throw 'ExampleAssembly must identify the built examples DLL.'
+ }
+ $packageFile = Get-Item -LiteralPath $PackageConsumerAssembly -ErrorAction Stop
+ if ($packageFile.PSIsContainer -or $packageFile.Extension -ine '.dll') {
+ throw 'PackageConsumerAssembly must identify the built package-consumer DLL.'
+ }
+ foreach ($assembly in @($testFile, $exampleFile, $packageFile)) {
+ if ($assembly.Directory.Name -cne $TargetFramework) {
+ throw "Every smoke assembly must come from the $TargetFramework output directory."
+ }
+ }
+
+ $actualHash = (Get-FileHash -LiteralPath $psmuxFile.FullName -Algorithm SHA256).Hash
+ if ($actualHash -ine $ExpectedSha256) {
+ throw "psmux SHA-256 mismatch: expected $ExpectedSha256, got $actualHash."
+ }
+
+ $binaryText = [Text.Encoding]::ASCII.GetString(
+ [IO.File]::ReadAllBytes($psmuxFile.FullName))
+ if (-not $binaryText.Contains('aa26cd3') -or
+ -not $binaryText.Contains('2026-08-17')) {
+ throw 'psmux does not contain the audited build markers.'
+ }
+
+ $DataDirectory = Get-IsolatedDataDirectory $DataDirectory
+ if (Test-Path -LiteralPath $DataDirectory) {
+ throw 'DataDirectory must not exist; choose a fresh high-entropy path for this run.'
+ }
+ New-Item -ItemType Directory -Path $DataDirectory | Out-Null
+ $dataDirectoryCreated = $true
+ $env:PSMUX_DATA_DIR = $DataDirectory
+ $env:PSMUX_NO_WARM = '1'
+
+ if ($RunWslSmoke) {
+ $wslRepositoryPath = Convert-ToWslPath `
+ $WslRepository $WslDistribution 'repository' $DataDirectory
+ $wslRuntimeMajor = '8'
+ if ($TargetFramework -ceq 'net10.0') {
+ $wslRuntimeMajor = '10'
+ }
+ $wslDotnetResult = Invoke-CapturedNative `
+ 'wsl.exe' `
+ @(
+ '--distribution', $WslDistribution,
+ '--cd', $wslRepositoryPath,
+ '--exec', '/bin/sh', '-eu', '-c',
+ $wslTimeoutScript,
+ 'libtmux-timeout', '15s',
+ '/bin/sh', '-eu', '-c',
+ $wslDotnetValidationScript,
+ 'libtmux-dotnet-validation',
+ $WslDotnetPath, $wslRuntimeMajor) `
+ $DataDirectory `
+ 30 `
+ 'WSL .NET executable validation' `
+ $true
+ if ($wslDotnetResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'WSL .NET executable validation' `
+ $wslDotnetResult.ExitCode `
+ $wslDotnetResult.Error)
+ }
+ if ($wslDotnetResult.Output.Count -ne 1 -or
+ [string]::IsNullOrWhiteSpace($wslDotnetResult.Output[0])) {
+ throw 'WSL .NET executable validation returned no canonical path.'
+ }
+ $resolvedWslDotnetPath = $wslDotnetResult.Output[0]
+ if ($resolvedWslDotnetPath -cne $resolvedWslDotnetPath.Trim() -or
+ -not $resolvedWslDotnetPath.StartsWith('/') -or
+ $resolvedWslDotnetPath.ToCharArray().Where(
+ { [char]::IsControl($_) }).Count -ne 0) {
+ throw 'WSL .NET executable validation returned an invalid canonical path.'
+ }
+ }
+
+ $bannerResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @('-V') `
+ $DataDirectory `
+ 30 `
+ 'psmux version query'
+ $banner = $bannerResult.Output
+ if ($bannerResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux version query' `
+ $bannerResult.ExitCode `
+ $bannerResult.Error)
+ }
+ if ([string]::Join("`n", $banner) -cne
+ [string]::Join("`n", $expectedBanner)) {
+ throw "psmux reported an unaudited banner: $([string]::Join(' | ', $banner))"
+ }
+
+ $configPath = Join-Path $DataDirectory 'libtmux-smoke.conf'
+ [IO.File]::WriteAllText(
+ $configPath,
+ "set -g warm off`n",
+ [Text.UTF8Encoding]::new($false))
+ $configCreated = $true
+
+ $existingResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @('-L', $NamespaceName, 'list-sessions', '-F', '#{session_name}') `
+ $DataDirectory `
+ 30 `
+ 'psmux isolated namespace inspection'
+ $existing = $existingResult.Output
+ if ($existingResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux isolated namespace inspection' `
+ $existingResult.ExitCode `
+ $existingResult.Error)
+ }
+ if ($existing.Count -ne 0) {
+ throw "The isolated namespace is not empty: $([string]::Join(', ', $existing))"
+ }
+
+ $creationAttempted = $true
+ $creationResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @(
+ '-f', $configPath,
+ '-L', $NamespaceName,
+ 'new-session',
+ '-d',
+ '-s', $SessionName,
+ '--', 'powershell.exe', '-NoLogo', '-NoProfile', '-NoExit') `
+ $DataDirectory `
+ 30 `
+ 'psmux session creation'
+ $creationExitCode = $creationResult.ExitCode
+
+ $identityFormat = "#{pid}:#{start_time}`t#{session_id}`t#{session_name}"
+ $escapedSessionName = [regex]::Escape($SessionName)
+ $identityPattern = '^[1-9][0-9]*:[1-9][0-9]*\t(\$[0-9]+)\t' +
+ $escapedSessionName + '$'
+ for ($attempt = 0; $attempt -lt 50 -and -not $createdSessionId; $attempt++) {
+ $identityResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @(
+ '-L', $NamespaceName,
+ 'display-message',
+ '-p',
+ '-t', $SessionName,
+ $identityFormat) `
+ $DataDirectory `
+ 30 `
+ 'psmux session identity query'
+ $identity = $identityResult.Output
+ if ($identityResult.ExitCode -eq 0 -and $identity.Count -eq 1 -and
+ $identity[0] -match $identityPattern) {
+ $candidatePid = [int] ($identity[0].Split(':')[0])
+ $candidateProcess = Get-Process -Id $candidatePid -ErrorAction Stop
+ $createdIdentity = $identity[0]
+ $createdPid = $candidatePid
+ $createdProcessStartTicks = $candidateProcess.StartTime.ToUniversalTime().Ticks
+ $createdSessionId = $Matches[1]
+ break
+ }
+
+ Start-Sleep -Milliseconds 100
+ }
+
+ if ($creationExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux new-session after the creation attempt' `
+ $creationExitCode `
+ $creationResult.Error)
+ }
+ if (-not $createdSessionId) {
+ throw 'psmux created no session with an exact verifiable identity.'
+ }
+
+ $sendResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @(
+ '-L', $NamespaceName,
+ 'send-keys',
+ '-t', "${SessionName}:0.0",
+ $fixtureCommand,
+ 'Enter') `
+ $DataDirectory `
+ 30 `
+ 'psmux fixture input'
+ if ($sendResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux fixture input' `
+ $sendResult.ExitCode `
+ $sendResult.Error)
+ }
+
+ $ready = $false
+ for ($attempt = 0; $attempt -lt 50 -and -not $ready; $attempt++) {
+ $captureResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @(
+ '-L', $NamespaceName,
+ 'capture-pane',
+ '-p',
+ '-t', "${SessionName}:0.0") `
+ $DataDirectory `
+ 30 `
+ 'psmux pane capture'
+ $capture = $captureResult.Output
+ if ($captureResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux pane capture' `
+ $captureResult.ExitCode `
+ $captureResult.Error)
+ }
+ $ready = $capture.Where({ $_ -clike "*$expectedText*" }).Count -gt 0
+ if (-not $ready) {
+ Start-Sleep -Milliseconds 100
+ }
+ }
+ if (-not $ready) {
+ throw 'The UTF-8 fixture did not become visible in the smoke pane.'
+ }
+
+ $env:LIBTMUX_PSMUX_BINARY = $psmuxFile.FullName
+ $env:LIBTMUX_PSMUX_EXPECTED_TEXT = $expectedText
+ $env:LIBTMUX_PSMUX_NAMESPACE = $NamespaceName
+ $env:LIBTMUX_PSMUX_SHA256 = $ExpectedSha256.ToLowerInvariant()
+ $env:LIBTMUX_PSMUX_SMOKE = '1'
+
+ $nativeResultPath = Join-Path $DataDirectory 'libtmux-native-result.xml'
+ $nativeTestResult = Invoke-CapturedNative `
+ $dotnetFile.FullName `
+ @(
+ $testFile.FullName,
+ '-noColor',
+ '-noLogo',
+ '-failSkips',
+ '-result-xml', $nativeResultPath,
+ '-class', 'LibTmux.UnitTests.Connection.PsmuxProcessSmokeTests') `
+ $DataDirectory `
+ 180 `
+ 'Native Windows .NET smoke' `
+ $true
+ $exitCode = $nativeTestResult.ExitCode
+ if ($exitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'Native Windows .NET smoke' `
+ $exitCode `
+ $nativeTestResult.Error)
+ }
+ Assert-OnePassingTest $nativeResultPath 'Native Windows .NET'
+ $nativeExampleResult = Invoke-CapturedNative `
+ $dotnetFile.FullName `
+ @($exampleFile.FullName, '--psmux') `
+ $DataDirectory `
+ 60 `
+ 'Native Windows example' `
+ $true
+ Assert-QueryProgram `
+ $nativeExampleResult.Output `
+ $nativeExampleResult.ExitCode `
+ $nativeExampleResult.Error `
+ $expectedText `
+ 'Native Windows example'
+ $nativePackageResult = Invoke-CapturedNative `
+ $dotnetFile.FullName `
+ @($packageFile.FullName, '--psmux') `
+ $DataDirectory `
+ 60 `
+ 'Native Windows packed consumer' `
+ $true
+ Assert-QueryProgram `
+ $nativePackageResult.Output `
+ $nativePackageResult.ExitCode `
+ $nativePackageResult.Error `
+ $expectedText `
+ 'Native Windows packed consumer'
+
+ if ($exitCode -eq 0 -and $RunWslSmoke) {
+ $wslPsmuxPath = Convert-ToWslPath `
+ $psmuxFile.FullName $WslDistribution 'audited psmux' $DataDirectory
+ $wslDataDirectory = Convert-ToWslPath `
+ $DataDirectory $WslDistribution 'isolated data-directory' $DataDirectory
+ $wslTestAssembly = Convert-ToWslPath `
+ $testFile.FullName $WslDistribution 'unit-test assembly' $DataDirectory
+ $wslExampleAssembly = Convert-ToWslPath `
+ $exampleFile.FullName $WslDistribution 'example assembly' $DataDirectory
+ $wslPackageAssembly = Convert-ToWslPath `
+ $packageFile.FullName $WslDistribution 'package-consumer assembly' $DataDirectory
+ $wslResultPath = Join-Path $DataDirectory 'libtmux-wsl-result.xml'
+ $wslResultArgument = "$($wslDataDirectory.TrimEnd('/'))/libtmux-wsl-result.xml"
+ $wslTestResult = Invoke-CapturedNative `
+ 'wsl.exe' `
+ @(
+ '--distribution', $WslDistribution,
+ '--cd', $wslRepositoryPath,
+ '--exec', '/bin/sh', '-eu', '-c',
+ $wslTimeoutScript,
+ 'libtmux-timeout', '240s',
+ '/usr/bin/env',
+ "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath",
+ "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText",
+ "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName",
+ "LIBTMUX_PSMUX_SHA256=$($ExpectedSha256.ToLowerInvariant())",
+ 'LIBTMUX_PSMUX_SMOKE=1',
+ "PSMUX_DATA_DIR=$DataDirectory",
+ 'WSLENV=PSMUX_DATA_DIR/w',
+ $resolvedWslDotnetPath,
+ $wslTestAssembly,
+ '-noColor',
+ '-noLogo',
+ '-failSkips',
+ '-result-xml', $wslResultArgument,
+ '-class', 'LibTmux.UnitTests.Connection.PsmuxProcessSmokeTests') `
+ $DataDirectory `
+ 300 `
+ 'WSL .NET smoke' `
+ $true
+ $exitCode = $wslTestResult.ExitCode
+ if ($exitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'WSL .NET smoke' `
+ $exitCode `
+ $wslTestResult.Error)
+ }
+ Assert-OnePassingTest $wslResultPath 'WSL .NET'
+ $wslExampleResult = Invoke-CapturedNative `
+ 'wsl.exe' `
+ @(
+ '--distribution', $WslDistribution,
+ '--cd', $wslRepositoryPath,
+ '--exec', '/bin/sh', '-eu', '-c',
+ $wslTimeoutScript,
+ 'libtmux-timeout', '120s',
+ '/usr/bin/env',
+ "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath",
+ "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText",
+ "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName",
+ "PSMUX_DATA_DIR=$DataDirectory",
+ 'WSLENV=PSMUX_DATA_DIR/w',
+ $resolvedWslDotnetPath,
+ $wslExampleAssembly,
+ '--psmux') `
+ $DataDirectory `
+ 180 `
+ 'WSL example' `
+ $true
+ Assert-QueryProgram `
+ $wslExampleResult.Output `
+ $wslExampleResult.ExitCode `
+ $wslExampleResult.Error `
+ $expectedText `
+ 'WSL example'
+
+ $wslPackageResult = Invoke-CapturedNative `
+ 'wsl.exe' `
+ @(
+ '--distribution', $WslDistribution,
+ '--cd', $wslRepositoryPath,
+ '--exec', '/bin/sh', '-eu', '-c',
+ $wslTimeoutScript,
+ 'libtmux-timeout', '120s',
+ '/usr/bin/env',
+ "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath",
+ "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText",
+ "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName",
+ "PSMUX_DATA_DIR=$DataDirectory",
+ 'WSLENV=PSMUX_DATA_DIR/w',
+ $resolvedWslDotnetPath,
+ $wslPackageAssembly,
+ '--psmux') `
+ $DataDirectory `
+ 180 `
+ 'WSL packed consumer' `
+ $true
+ Assert-QueryProgram `
+ $wslPackageResult.Output `
+ $wslPackageResult.ExitCode `
+ $wslPackageResult.Error `
+ $expectedText `
+ 'WSL packed consumer'
+ }
+}
+catch {
+ $primaryFailure = $_
+}
+finally {
+ try {
+ if ($creationAttempted) {
+ $ownedSidPath = Join-Path $DataDirectory "$NamespaceName`__$SessionName.sid"
+ if (-not $createdSessionId) {
+ throw 'A creation attempt has no exact identity; retaining its data directory.'
+ }
+ else {
+ # aa26 resolves $N to its complete namespaced registry base;
+ # adding -L here would prefix the namespace a second time.
+ $currentIdentityResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @(
+ 'display-message',
+ '-p',
+ '-t', $createdSessionId,
+ $identityFormat) `
+ $DataDirectory `
+ 30 `
+ 'psmux cleanup identity query'
+ $currentIdentity = $currentIdentityResult.Output
+ if ($currentIdentityResult.ExitCode -ne 0) {
+ if (Test-Path -LiteralPath $ownedSidPath) {
+ throw (Get-NativeExitMessage `
+ 'psmux cleanup identity query' `
+ $currentIdentityResult.ExitCode `
+ $currentIdentityResult.Error)
+ }
+ }
+ elseif ($currentIdentity.Count -ne 1) {
+ if (Test-Path -LiteralPath $ownedSidPath) {
+ throw 'The created session still exists but its identity cannot be verified.'
+ }
+ }
+ elseif ($currentIdentity[0] -cne $createdIdentity) {
+ throw 'The created session identity changed; refusing to kill its replacement.'
+ }
+ else {
+ $killResult = Invoke-CapturedNative `
+ $psmuxFile.FullName `
+ @('kill-session', '-t', $createdSessionId) `
+ $DataDirectory `
+ 30 `
+ 'psmux exact session cleanup'
+ if ($killResult.ExitCode -ne 0) {
+ throw (Get-NativeExitMessage `
+ 'psmux exact session cleanup' `
+ $killResult.ExitCode `
+ $killResult.Error)
+ }
+
+ for ($attempt = 0; $attempt -lt 50 -and
+ (Test-Path -LiteralPath $ownedSidPath); $attempt++) {
+ Start-Sleep -Milliseconds 100
+ }
+ if (Test-Path -LiteralPath $ownedSidPath) {
+ throw 'The exact created session registry survived cleanup.'
+ }
+ }
+
+ for ($attempt = 0; $attempt -lt 50 -and
+ (Test-ExactProcessAlive `
+ $createdPid $createdProcessStartTicks); $attempt++) {
+ Start-Sleep -Milliseconds 100
+ }
+ if (Test-ExactProcessAlive $createdPid $createdProcessStartTicks) {
+ throw 'The exact created session process survived cleanup.'
+ }
+ }
+ }
+ }
+ catch {
+ $cleanupFailures.Add($_.Exception)
+ }
+
+ foreach ($ownedPath in @($nativeResultPath, $wslResultPath, $configPath)) {
+ try {
+ if ($ownedPath -and (Test-Path -LiteralPath $ownedPath -PathType Leaf)) {
+ if ($ownedPath -eq $configPath -and -not $configCreated) {
+ throw 'Refusing to remove a configuration file this run did not create.'
+ }
+ Remove-Item -LiteralPath $ownedPath -Force
+ }
+ }
+ catch {
+ $cleanupFailures.Add($_.Exception)
+ }
+ }
+
+ try {
+ if ($dataDirectoryCreated -and $cleanupFailures.Count -eq 0 -and
+ (Test-Path -LiteralPath $DataDirectory -PathType Container)) {
+ $liveRegistry = @(Get-ChildItem `
+ -LiteralPath $DataDirectory `
+ -Filter '*.port' `
+ -Recurse `
+ -ErrorAction Stop)
+ if ($liveRegistry.Count -ne 0) {
+ throw 'The owned data directory still contains a live psmux registry.'
+ }
+ Remove-Item -LiteralPath $DataDirectory -Recurse -Force
+ }
+ }
+ catch {
+ $cleanupFailures.Add($_.Exception)
+ }
+
+ try {
+ foreach ($name in $savedNames) {
+ [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process')
+ }
+ }
+ catch {
+ $cleanupFailures.Add($_.Exception)
+ }
+
+ try {
+ [Console]::OutputEncoding = $savedOutputEncoding
+ }
+ catch {
+ $cleanupFailures.Add($_.Exception)
+ }
+}
+
+if ($primaryFailure) {
+ if ($cleanupFailures.Count -gt 0) {
+ $primaryFailure.Exception.Data['LibTmux.PsmuxSmokeCleanupFailure'] =
+ [System.AggregateException]::new($cleanupFailures)
+ }
+ throw $primaryFailure
+}
+if ($cleanupFailures.Count -gt 0) {
+ throw [System.AggregateException]::new(
+ 'The psmux smoke cleanup failed.',
+ $cleanupFailures)
+}
+
+exit $exitCode
diff --git a/eng/psmux/tests/test_smoke_harness.py b/eng/psmux/tests/test_smoke_harness.py
new file mode 100644
index 0000000..b1f10e8
--- /dev/null
+++ b/eng/psmux/tests/test_smoke_harness.py
@@ -0,0 +1,250 @@
+"""Keep the native psmux harness fail-closed before its first process launch."""
+
+from __future__ import annotations
+
+import pathlib
+import re
+
+
+SCRIPT = pathlib.Path(__file__).parents[1] / "Invoke-PsmuxSmoke.ps1"
+REPOSITORY = pathlib.Path(__file__).parents[3]
+EXAMPLE_PROGRAM = REPOSITORY / "examples" / "LibTmux.Examples" / "Program.cs"
+PACKAGE_PROGRAM = REPOSITORY / "tests" / "LibTmux.PackageConsumer" / "Program.cs"
+
+
+def source() -> str:
+ """Return the checked-in PowerShell harness."""
+ return SCRIPT.read_text(encoding="utf-8")
+
+
+def test_first_psmux_launch_uses_verified_binary_and_isolated_data() -> None:
+ """Unsafe release binaries must be rejected without ever executing them."""
+ script = source()
+ first_launch = script.index("$bannerResult = Invoke-CapturedNative")
+
+ assert script.index("Get-FileHash", 0, first_launch) >= 0
+ assert script.index("$ExpectedSha256 -ine $supportedSha256", 0, first_launch) >= 0
+ assert "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e" in script
+ assert script.index("$binaryText.Contains('aa26cd3')", 0, first_launch) >= 0
+ assert script.index("$binaryText.Contains('2026-08-17')", 0, first_launch) >= 0
+ assert script.index("$env:PSMUX_DATA_DIR = $DataDirectory", 0, first_launch) >= 0
+ assert script.index("$env:PSMUX_NO_WARM = '1'", 0, first_launch) >= 0
+ assert '"set -g warm off`n"' in script
+ assert "PsmuxPath must be local to a Windows drive" in script
+ assert script.index("WslDotnetPath must be", 0, first_launch) >= 0
+ assert script.index("WslRepository must be", 0, first_launch) >= 0
+ assert script.index("$wslDotnetResult = Invoke-CapturedNative", 0, first_launch) >= 0
+
+
+def test_native_processes_are_bounded_and_return_explicit_status() -> None:
+ """PowerShell must not infer success from a missing or stale exit code."""
+ script = source()
+
+ assert "$LASTEXITCODE" not in script
+ assert "@(&" not in script
+ assert script.count("Invoke-CapturedNative `") == 17
+ assert "Start-Process" not in script
+ assert "$startInfo = [Diagnostics.ProcessStartInfo]::new()" in script
+ assert "$startInfo.WorkingDirectory = $CaptureDirectory" in script
+ assert "$startInfo.UseShellExecute = $false" in script
+ assert "$startInfo.RedirectStandardOutput = $true" in script
+ assert "$startInfo.RedirectStandardError = $true" in script
+ assert "$process.StandardOutput.BaseStream.CopyToAsync($stdoutBuffer)" in script
+ assert "$process.StandardError.BaseStream.CopyToAsync($stderrBuffer)" in script
+ assert "$null = $stdoutTask.GetAwaiter().GetResult()" in script
+ assert "$null = $stderrTask.GetAwaiter().GetResult()" in script
+ assert "$startInfo.StandardOutputEncoding" not in script
+ assert "$startInfo.StandardErrorEncoding" not in script
+ assert "ConvertFrom-Utf8Bytes $stdoutBuffer.ToArray() $Leg 'stdout'" in script
+ assert "ConvertFrom-Utf8Bytes $stderrBuffer.ToArray() $Leg 'stderr'" in script
+ assert '"$Leg emitted invalid UTF-8 on $StreamName."' in script
+ assert "$process.WaitForExit($TimeoutSeconds * 1000)" in script
+ assert "$process.Kill()" in script
+ assert "$process.WaitForExit(5000)" in script
+ assert "$exitCode = $process.ExitCode" in script
+ assert "ExitCode = $exitCode" in script
+ assert "ConvertTo-NativeArgument" in script
+ assert "$KillDescendantsOnTimeout = $false" in script
+ assert "@('/PID', $process.Id.ToString(), '/T', '/F')" in script
+ assert '"$Leg process-tree termination"' in script
+
+
+def test_wsl_workloads_have_inner_process_group_deadlines() -> None:
+ """Killing wsl.exe alone must not leave its adopted Linux workload alive."""
+ script = source()
+
+ assert "'timeout (GNU coreutils) '*" in script
+ assert "LC_ALL=C /usr/bin/timeout --version" in script
+ assert 'exec /usr/bin/timeout --signal=KILL "$deadline" "$@"' in script
+ assert "--kill-after" not in script
+ assert "--foreground" not in script
+ assert script.count("$wslTimeoutScript,") == 5
+ assert script.count("'libtmux-timeout'") == 5
+ assert script.count("'15s'") == 2
+ assert script.count("'240s'") == 1
+ assert script.count("'120s'") == 2
+ assert '"WSL $Kind path $operation" `\n $true' in script
+ assert "'WSL .NET smoke' `\n $true" in script
+ assert "'WSL example' `\n $true" in script
+ assert "'WSL packed consumer' `\n $true" in script
+
+
+def test_wsl_dotnet_is_validated_once_and_invoked_by_absolute_path() -> None:
+ """Non-login WSL calls must not search an incomplete inherited PATH."""
+ script = source()
+
+ assert "[string] $WslDotnetPath" in script
+ assert "candidate=$1" in script
+ assert 'resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126' in script
+ assert '[ -f "$resolved" ] && [ -x "$resolved" ]' in script
+ assert '"$resolved" --info >/dev/null || exit 126' in script
+ assert 'runtimes=$("$resolved" --list-runtimes) || exit 126' in script
+ assert 'Microsoft.NETCore.App $framework_major.' in script
+ assert '"required Microsoft.NETCore.App $framework_major runtime is unavailable"' in script
+ assert "$wslRuntimeMajor = '8'" in script
+ assert "$wslRuntimeMajor = '10'" in script
+ assert "$WslDotnetPath, $wslRuntimeMajor" in script
+ assert "'WSL .NET executable validation' `\n $true" in script
+ assert "$wslRepositoryPath = Convert-ToWslPath" in script
+ assert script.count("'--cd', $wslRepositoryPath") == 4
+ assert "$resolvedWslDotnetPath = $wslDotnetResult.Output[0]" in script
+ assert "$resolvedWslDotnetPath.StartsWith('/')" in script
+ assert script.count("$resolvedWslDotnetPath,") == 3
+ assert re.search(r"^\s*\$WslDotnetPath\s*=", script, re.IGNORECASE | re.MULTILINE) is None
+ assert script.count("'/bin/sh'") == 7
+ assert script.count("'/usr/bin/env'") == 3
+ assert script.count("/usr/bin/wslpath") == 1
+ assert "command -v mise" not in script
+ assert "'-lc'" not in script
+
+
+def test_wsl_repository_is_canonicalized_for_both_input_forms() -> None:
+ """Linux paths must bypass wslpath while every repository becomes a real directory."""
+ script = source()
+
+ assert "$pathMode = 'windows'" in script
+ assert "if ($Path.StartsWith('/'))" in script
+ assert "$pathMode = 'linux'" in script
+ assert 'candidate=$(/usr/bin/wslpath -u -- "$candidate") || exit 126' in script
+ assert 'resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126' in script
+ assert '[ -d "$resolved" ] || exit 126' in script
+ assert "$pathRequirement = 'directory'" in script
+ assert "$wslPathResolutionScript," in script
+ assert "$pathMode, $pathRequirement, $Path" in script
+ assert 'return $resolvedPath' in script
+
+
+def test_harness_owns_every_file_and_exact_cleanup_target() -> None:
+ """The harness must not overwrite or broadly clean caller data."""
+ script = source()
+
+ assert "DataDirectory must not exist" in script
+ assert "absolute path on a local Windows drive" in script
+ assert script.count("[IO.DriveType]::Fixed") == 2
+ assert r"if ($Path -notmatch '^[A-Za-z]:\\')" in script
+ assert "GetEnvironmentVariables('Process').GetEnumerator()" in script
+ assert "StartsWith('PSMUX_', [System.StringComparison]::OrdinalIgnoreCase)" in script
+ assert "'PSMUX_NO_WARM'" in script
+ assert "'LIBTMUX_PSMUX_'" in script
+ assert "New-Item -ItemType Directory -Path $DataDirectory | Out-Null" in script
+ assert "New-Item -ItemType Directory -Path $DataDirectory -Force" not in script
+ assert "kill-server" not in script
+ creation = script.index("new-session")
+ identity = script.index("$createdSessionId = $Matches[1]", creation)
+ creation_error = script.index("if ($creationExitCode -ne 0)", identity)
+ cleanup = script.index("if ($creationAttempted)", creation_error)
+
+ assert script.index("$creationAttempted = $true", 0, creation) >= 0
+ assert identity < creation_error
+ assert "@('kill-session', '-t', $createdSessionId)" in script[cleanup:]
+ assert "-L $NamespaceName" not in script[cleanup:]
+ assert "kill-session -t $SessionName" not in script
+ assert "$currentIdentity[0] -cne $createdIdentity" in script[cleanup:]
+ assert "refusing to kill its replacement" in script[cleanup:]
+ assert "no exact identity; retaining its data directory" in script[cleanup:]
+ assert "Test-ExactProcessAlive $createdPid $createdProcessStartTicks" in script[cleanup:]
+ assert "[long] $StartTimeUtcTicks" in script
+ assert "-Recurse" in script[cleanup:]
+ assert "The owned data directory still contains a live psmux registry" in script
+ assert "Remove-Item -LiteralPath $DataDirectory -Recurse -Force" in script
+ assert "Refusing to remove a configuration file this run did not create" in script
+
+
+def test_each_runtime_leg_requires_one_non_skipped_test() -> None:
+ """A stale class name or a skipped smoke must not produce a green harness."""
+ script = source()
+
+ assert script.count("-failSkips") == 2
+ assert script.count("-result-xml") == 2
+ assert "Assert-OnePassingTest $nativeResultPath 'Native Windows .NET'" in script
+ assert "Assert-OnePassingTest $wslResultPath 'WSL .NET'" in script
+ assert "'Native Windows .NET smoke' `\n $exitCode `\n $nativeTestResult.Error" in script
+ assert "'WSL .NET smoke' `\n $exitCode `\n $wslTestResult.Error" in script
+ assert "[int] $assemblies[0].total -ne 1" in script
+ assert "[int] $assemblies[0].passed -ne 1" in script
+ assert "[string] $ExampleAssembly" in script
+ assert "[string] $PackageConsumerAssembly" in script
+ assert "[ValidateSet('net8.0', 'net10.0')]" in script
+ assert "$assembly.Directory.Name -cne $TargetFramework" in script
+ assert "'Native Windows example'" in script
+ assert "'Native Windows packed consumer'" in script
+ assert "'WSL example'" in script
+ assert "'WSL packed consumer'" in script
+ assert script.count("Assert-QueryProgram") == 5
+
+
+def test_nonzero_process_details_are_bounded_and_preserve_exit_status() -> None:
+ """A useful stderr excerpt must not displace the primary native failure."""
+ script = source()
+
+ assert "'[\\p{Cc}\\p{Cf}]+'" in script
+ assert "[regex]::Replace($detail, '\\s+', ' ').Trim()" in script
+ assert "$detail.Length -gt 512" in script
+ assert "'...' + $detail.Substring($detail.Length - 509)" in script
+ assert 'return "$message stderr: $detail"' in script
+ assert '"$Leg exited $exitCode. $($_.Exception.Message)"' in script
+ timeout = script.index("$timedOut = $true")
+ termination = script.index("$process.WaitForExit(5000)", timeout)
+ drain = script.index("$null = $stdoutTask.GetAwaiter().GetResult()", termination)
+ report = script.index("$timeoutMessage =", drain)
+ assert timeout < termination < drain < report
+ for result in (
+ "$nativeExampleResult.Error",
+ "$nativePackageResult.Error",
+ "$wslTestResult.Error",
+ "$wslExampleResult.Error",
+ "$wslPackageResult.Error",
+ ):
+ assert result in script
+
+
+def test_windows_powershell_source_is_ascii_and_restores_process_state() -> None:
+ """Windows PowerShell 5.1 must parse the same UTF-8 fixture without a BOM."""
+ script = source()
+
+ script.encode("ascii")
+ assert "[Text.Encoding]::UTF8.GetString($fixtureBytes)" in script
+ assert "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)" in script
+ assert "$fixtureByteList" in script
+ assert '"Write-Output \'$expectedText\'"' not in script
+ assert "$fixtureCommand," in script
+ assert "foreach ($name in $savedNames)" in script
+ assert "[Console]::OutputEncoding = $savedOutputEncoding" in script
+ assert "LibTmux.PsmuxSmokeCleanupFailure" in script
+
+
+def test_native_managed_psmux_producers_force_strict_utf8() -> None:
+ """Redirected Windows apps must not inherit the unrepresentable OEM code page."""
+ for path, invocation in (
+ (EXAMPLE_PROGRAM, "await Snippets.Psmux.QueryPsmux();"),
+ (PACKAGE_PROGRAM, "return await RunPsmuxAsync();"),
+ ):
+ program = path.read_text(encoding="utf-8")
+ branch = program.index('if (args is ["--psmux"])')
+ encoding = program.index(
+ "Console.OutputEncoding = new UTF8Encoding(false, true);",
+ branch,
+ )
+ output = program.index(invocation, encoding)
+
+ assert branch < encoding < output
diff --git a/examples/LibTmux.Examples/ExampleAttribute.cs b/examples/LibTmux.Examples/ExampleAttribute.cs
index 4a133cc..a9edb7a 100644
--- a/examples/LibTmux.Examples/ExampleAttribute.cs
+++ b/examples/LibTmux.Examples/ExampleAttribute.cs
@@ -1,6 +1,6 @@
namespace LibTmux.Examples;
-/// Marks a method as an example that runs against a live tmux server.
+/// Marks a method as a published example.
///
/// The method name identifies the example, and names the #region a
/// document publishes from it.
@@ -14,4 +14,7 @@ public sealed class ExampleAttribute : Attribute
/// Gets the line saying what the example shows.
public string Title { get; }
+
+ /// Gets or sets whether the ordinary tmux example suite runs this example.
+ public bool RunsInDefaultSuite { get; set; } = true;
}
diff --git a/examples/LibTmux.Examples/ExampleCase.cs b/examples/LibTmux.Examples/ExampleCase.cs
index 7ac8241..3b6ad44 100644
--- a/examples/LibTmux.Examples/ExampleCase.cs
+++ b/examples/LibTmux.Examples/ExampleCase.cs
@@ -30,15 +30,15 @@ private ExampleCase(MethodInfo method, string title)
private MethodInfo Method { get; }
- /// Finds every example in this assembly, in a stable order.
- /// The examples, ordered by topic and then by name.
+ /// Finds the ordinary tmux examples, in a stable order.
+ /// The default-suite examples, ordered by topic and then by name.
public static IReadOnlyList Discover() =>
[
.. typeof(ExampleCase).Assembly
.GetTypes()
.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static))
.Select(method => (Method: method, Example: method.GetCustomAttribute()))
- .Where(found => found.Example is not null)
+ .Where(found => found.Example?.RunsInDefaultSuite is true)
.Select(found => Create(found.Method, found.Example!))
.OrderBy(example => example.Topic, StringComparer.Ordinal)
.ThenBy(example => example.Id, StringComparer.Ordinal),
diff --git a/examples/LibTmux.Examples/LibTmux.Examples.csproj b/examples/LibTmux.Examples/LibTmux.Examples.csproj
index 42958a6..400cb3c 100644
--- a/examples/LibTmux.Examples/LibTmux.Examples.csproj
+++ b/examples/LibTmux.Examples/LibTmux.Examples.csproj
@@ -1,7 +1,7 @@
Exe
- net10.0
+ net8.0;net10.0
LibTmux.Examples
false
true
diff --git a/examples/LibTmux.Examples/Program.cs b/examples/LibTmux.Examples/Program.cs
index 2539e75..28f0f7b 100644
--- a/examples/LibTmux.Examples/Program.cs
+++ b/examples/LibTmux.Examples/Program.cs
@@ -1,24 +1,44 @@
using System.Diagnostics;
using System.Runtime.Versioning;
+using System.Text;
namespace LibTmux.Examples;
-/// Runs every example against a tmux server of its own.
+/// Runs the example suite selected on the command line.
///
/// The same list LibTmux.ExampleTests runs, on the console instead of
/// in a test report.
///
-[UnsupportedOSPlatform("windows")]
internal static class Program
{
- private static async Task Main()
+ private static async Task Main(string[] args)
{
+ if (args is ["--psmux"])
+ {
+ Console.OutputEncoding = new UTF8Encoding(false, true);
+ await Snippets.Psmux.QueryPsmux();
+ return 0;
+ }
+
+ if (args.Length != 0)
+ {
+ Console.Error.WriteLine("usage: LibTmux.Examples [--psmux]");
+ return 2;
+ }
+
if (OperatingSystem.IsWindows())
{
- Console.Error.WriteLine("tmux does not run on Windows.");
+ Console.Error.WriteLine(
+ "The ordinary examples require tmux on Linux or macOS; use --psmux for the Windows query preview.");
return 1;
}
+ return await RunTmuxExamplesAsync();
+ }
+
+ [UnsupportedOSPlatform("windows")]
+ private static async Task RunTmuxExamplesAsync()
+ {
int failed = 0;
foreach (ExampleCase example in ExampleCase.Discover())
{
diff --git a/examples/LibTmux.Examples/Snippets/ControlMode.cs b/examples/LibTmux.Examples/Snippets/ControlMode.cs
index ac80860..591cfbf 100644
--- a/examples/LibTmux.Examples/Snippets/ControlMode.cs
+++ b/examples/LibTmux.Examples/Snippets/ControlMode.cs
@@ -25,4 +25,31 @@ public static async Task WatchForWindowAdd(Server server, CancellationToken ct)
}
#endregion
}
+
+ /// Reads the marker that says the event buffer discarded events.
+ [Example("React to a control stream that fell behind")]
+ public static async Task NoticeDroppedEvents(Server server, CancellationToken ct)
+ {
+ #region NoticeDroppedEvents
+ await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct);
+
+ await control.SendAsync("new-window -d -n build", ct);
+
+ await foreach (TmuxEvent observed in control.Events.WithCancellation(ct))
+ {
+ if (observed is TmuxEventsDroppedEvent dropped)
+ {
+ // Anything cached from this stream is now a guess, so the
+ // marker is a signal to re-read rather than to log.
+ Console.WriteLine($"missed {dropped.Count}, {dropped.TotalDropped} in total");
+ continue;
+ }
+
+ if (observed is TmuxNotificationEvent { Name: "window-add" })
+ {
+ break;
+ }
+ }
+ #endregion
+ }
}
diff --git a/examples/LibTmux.Examples/Snippets/Psmux.cs b/examples/LibTmux.Examples/Snippets/Psmux.cs
new file mode 100644
index 0000000..9acc4cc
--- /dev/null
+++ b/examples/LibTmux.Examples/Snippets/Psmux.cs
@@ -0,0 +1,50 @@
+namespace LibTmux.Examples.Snippets;
+
+/// Compile-checked psmux examples published by the Windows preview guide.
+public static class Psmux
+{
+ /// Reads the sole session, its windows, its panes, and pane text.
+ [Example(
+ "Query one pinned psmux namespace from Windows or WSL",
+ RunsInDefaultSuite = false)]
+ public static async Task QueryPsmux()
+ {
+ #region QueryPsmux
+ using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30));
+ CancellationToken cancellationToken = cancellation.Token;
+
+ string executable = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY")
+ ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required.");
+ string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR")
+ ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required.");
+ string namespaceName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE")
+ ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required.");
+
+ PsmuxServer server = await PsmuxServer.ConnectAsync(
+ new PsmuxConnectionOptions(
+ executablePath: executable,
+ expectedBinarySha256: PsmuxServer.SupportedBinarySha256,
+ dataDirectory: dataDirectory,
+ namespaceName: namespaceName),
+ cancellationToken);
+ PsmuxSession session = await server.GetSessionAsync(cancellationToken);
+
+ Console.WriteLine($"{session.Id} {session.Name}");
+ foreach (PsmuxWindow window in await session.GetWindowsAsync(cancellationToken))
+ {
+ Console.WriteLine($" {window.Id} {window.Index}: {window.Name}");
+ foreach (PsmuxPane pane in await window.GetPanesAsync(cancellationToken))
+ {
+ IReadOnlyList lines = await pane.CaptureAsync(
+ new PsmuxCaptureOptions(joinWrappedLines: true),
+ cancellationToken);
+ Console.WriteLine($" {pane.Id} {pane.Width}x{pane.Height}");
+ foreach (string line in lines)
+ {
+ Console.WriteLine($" {line}");
+ }
+ }
+ }
+ #endregion
+ }
+}
diff --git a/examples/LibTmux.Examples/packages.lock.json b/examples/LibTmux.Examples/packages.lock.json
index 22b6c41..3b54e68 100644
--- a/examples/LibTmux.Examples/packages.lock.json
+++ b/examples/LibTmux.Examples/packages.lock.json
@@ -280,7 +280,7 @@
"libtmux.mcp": {
"type": "Project",
"dependencies": {
- "LibTmux": "[0.0.0-alpha.6, )",
+ "LibTmux": "[0.0.0-alpha.8, )",
"Microsoft.Extensions.Hosting": "[10.0.11, )",
"ModelContextProtocol": "[2.2.0, )",
"ModelContextProtocol.Extensions.Tasks": "[2.2.0, )"
@@ -345,6 +345,390 @@
"ModelContextProtocol": "[2.2.0]"
}
}
+ },
+ "net8.0": {
+ "Microsoft.Extensions.AI.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.8.3",
+ "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==",
+ "dependencies": {
+ "System.Text.Json": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Caching.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Binder": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.CommandLine": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "1KHr/1L56llwQ/yI0tAisEA31UpPsn8aasjASIwELOaN4JIUcbjuQBMdFOIzfNBBeULoUa0XfBe5QDtRRUY+fg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "KICyU3eVi5jvloKm01EXV69L97H/zkhISVtV98cIuzuFOxNx3xTUVcXqvWTz3aq7OvUuDB/MFlPFjmxRaKF7/A==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.FileExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "mDW7KVFB05M6jiRUyaZiOMWhS31n5HlSZwoYctHAZAucD4sMDJ70IxOmkGDt6RpstchD+keWBjhdzcMpSkWvWQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Json": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "nSPrT8U/cNoB4coqkmnanAMK9PsL7lsjG+LLUKEwHRFwS6E78b8S1wdv/y88EOxBhasWov1rLd7RTHmmsYPOLg==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "System.Text.Json": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Configuration.UserSecrets": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "BRliLdUowglV8GS+J1G/QsSofCJYYFg3U8QZx0ACRn+a91az/Qnpy+h6PyHS94WgV2TSazX5D/cuuk6wnCJatw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Configuration.Json": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg=="
+ },
+ "Microsoft.Extensions.Diagnostics": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Diagnostics.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "System.Diagnostics.DiagnosticSource": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.FileProviders.Physical": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "Tq/UqMaczePv9yWwSsJZRgKtgA46djVR5xHj/lZBCueQ3ag8f9v5mu0EdhrNx7tXxNk+Y9OurG2oKuSKINjr0A==",
+ "dependencies": {
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileSystemGlobbing": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "2i6rtW/B5rCnWCnhdmWWEmaM9O0HD0zsPY9eRqa++y4tclI3Uw8zvGbBvhY/LjAdtf8gUHhUPcAWj3DRlWMXmQ=="
+ },
+ "Microsoft.Extensions.Hosting.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.Configuration": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "S7LvLeVHKNPaY2NMyxW7c2TBGsLgxoSUBCV5Ev5iN8kgC7EPR2UB7eW7vHsElGMcIUDwRmoxLfvGDynCn3q6EA==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.11",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.Console": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "dFc0yDudyD1iIg6z9XT7ofsT3hVO7Y4ylrxGHIVRR0GaZ4CUk4ujOrMoy7wWEdNHZhvJooySg6hZpOxyS8zEVA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "System.Text.Json": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.Debug": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "wr+j1bjdFXhc8lKTLoq+RbwFM8M+orcMS9xrcqLmDGOxJcXpKizEeE5h6v/GKwCZV02FmhaA7OlNjoq072jZpQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "Eck9GpCCpvZ3f6L7IUlN+mPtRVefnf7PsiIG5vi61QawPtLNCEAv2TPD/M3SojcU0PFaef+BxiVGPOShFHtDog==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "System.Diagnostics.EventLog": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.EventSource": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "hs6QWECLLohi2VKqUvSGRUvrg7eXR1DqKL95Jrtz3cdD2g2nBA+yJPdRQLZ7SLmnTZWycxfMDK2s0ho+rfst5w==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11",
+ "System.Text.Json": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Options.ConfigurationExtensions": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.11",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11",
+ "Microsoft.Extensions.Primitives": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw=="
+ },
+ "ModelContextProtocol.Core": {
+ "type": "Transitive",
+ "resolved": "2.2.0",
+ "contentHash": "FeBfXU6T8k+jw4afg4sfxdEX2rL/e5oKOk9ROOGztu9k47+7Bz08sdaToYt2XvMY1opNbwxYQOFMj6wH9TInhA==",
+ "dependencies": {
+ "Microsoft.Extensions.AI.Abstractions": "10.8.3",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "System.IO.Pipelines": "10.0.10",
+ "System.Net.ServerSentEvents": "10.0.10"
+ }
+ },
+ "System.Diagnostics.DiagnosticSource": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "QTXEoQBzz00SFWbo7nAg1Ogd4f99lwqcO9uAJ7MYSLEUR28f6As32QktrqG2Fr9cfAfd1GjLyGYspE7Ipj7P6w=="
+ },
+ "System.IO.Pipelines": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "rg8LPOZ62quw3aTnsQNh9rssncaMs69WEM1DiJdDkV+o4Llb2s5Pasy5Bulfm8zL+pE4gDcSQo7V2zW2jvxwYg=="
+ },
+ "System.Net.ServerSentEvents": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw=="
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "R7H2/Oqr3onFff/JKDpfRjwxcQZlocF/eQ0ce8go/OTjqz+6LEp/fZK8j1YnDobtysChATEg7krVq4hQJ3IoeQ=="
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "10.0.11",
+ "contentHash": "X2rXQy7g8KEUvWVbAz6vOk6byI1eMgmzFeXbKxq4BfZCfFy5S91K+K+hd4ZBlSp0wL1Y8FyGRXs6+hvABTjwQw==",
+ "dependencies": {
+ "System.IO.Pipelines": "10.0.11",
+ "System.Text.Encodings.Web": "10.0.11"
+ }
+ },
+ "libtmux": {
+ "type": "Project",
+ "dependencies": {
+ "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )"
+ }
+ },
+ "libtmux.mcp": {
+ "type": "Project",
+ "dependencies": {
+ "LibTmux": "[0.0.0-alpha.8, )",
+ "Microsoft.Extensions.Hosting": "[10.0.11, )",
+ "ModelContextProtocol": "[2.2.0, )",
+ "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )"
+ }
+ },
+ "Microsoft.Extensions.Hosting": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.11, )",
+ "resolved": "10.0.11",
+ "contentHash": "eIDa/Rl+93aj17gMlFsJJx+LhBvb3CP0Mu1PeVYkDp2Y3S4Jock8UynfGQEcx7lrlq+gKW+ECQJHbro/LTPDEQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Configuration": "10.0.11",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Configuration.Binder": "10.0.11",
+ "Microsoft.Extensions.Configuration.CommandLine": "10.0.11",
+ "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.11",
+ "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11",
+ "Microsoft.Extensions.Configuration.Json": "10.0.11",
+ "Microsoft.Extensions.Configuration.UserSecrets": "10.0.11",
+ "Microsoft.Extensions.DependencyInjection": "10.0.11",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Diagnostics": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11",
+ "Microsoft.Extensions.FileProviders.Physical": "10.0.11",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging": "10.0.11",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.11",
+ "Microsoft.Extensions.Logging.Configuration": "10.0.11",
+ "Microsoft.Extensions.Logging.Console": "10.0.11",
+ "Microsoft.Extensions.Logging.Debug": "10.0.11",
+ "Microsoft.Extensions.Logging.EventLog": "10.0.11",
+ "Microsoft.Extensions.Logging.EventSource": "10.0.11",
+ "Microsoft.Extensions.Options": "10.0.11"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "10.0.11",
+ "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11",
+ "System.Diagnostics.DiagnosticSource": "10.0.11"
+ }
+ },
+ "ModelContextProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[2.2.0, )",
+ "resolved": "2.2.0",
+ "contentHash": "4Pb9u02Nwsp0poueDsqNdyGRojFxOYpljB7zDBsq+aHL+Afou3OgxlBc3GWFVnsRMRJrUtWqDh3s6k2JgPzmrQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Hosting.Abstractions": "10.0.10",
+ "ModelContextProtocol.Core": "[2.2.0]"
+ }
+ },
+ "ModelContextProtocol.Extensions.Tasks": {
+ "type": "CentralTransitive",
+ "requested": "[2.2.0, )",
+ "resolved": "2.2.0",
+ "contentHash": "cIGGEIL/KVbPBxflSF6TRKmjNTGk668e8x/R+Jx9l8VPHH70BisCmjdtHdkDHbbBr9gGMbjBjJdiNMJecweCGw==",
+ "dependencies": {
+ "ModelContextProtocol": "[2.2.0]"
+ }
+ }
}
}
-}
\ No newline at end of file
+}
diff --git a/src/LibTmux.Mcp/Diagnostics/Log.cs b/src/LibTmux.Mcp/Diagnostics/Log.cs
index e96cd00..fc0267c 100644
--- a/src/LibTmux.Mcp/Diagnostics/Log.cs
+++ b/src/LibTmux.Mcp/Diagnostics/Log.cs
@@ -63,4 +63,32 @@ internal static partial void ControlClientUnavailable(
Level = LogLevel.Warning,
Message = "Tool {Tool} failed.")]
internal static partial void ToolFailed(ILogger logger, Exception error, string tool);
+
+ [LoggerMessage(
+ EventId = 8,
+ Level = LogLevel.Debug,
+ Message = "Control client for socket {Socket} could not be cleaned up.")]
+ internal static partial void ControlClientCleanupFailed(
+ ILogger logger,
+ Exception error,
+ string? socket);
+
+ [LoggerMessage(
+ EventId = 9,
+ Level = LogLevel.Warning,
+ Message = "Background job {JobId} in pane {PaneId} could no longer be watched.")]
+ internal static partial void JobWatcherFailed(
+ ILogger logger,
+ Exception error,
+ string jobId,
+ string paneId);
+
+ [LoggerMessage(
+ EventId = 10,
+ Level = LogLevel.Debug,
+ Message = "Hierarchy subscriber callback for endpoint {Endpoint} failed.")]
+ internal static partial void HierarchySubscriberFailed(
+ ILogger logger,
+ Exception error,
+ string endpoint);
}
diff --git a/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs b/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs
new file mode 100644
index 0000000..2795165
--- /dev/null
+++ b/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs
@@ -0,0 +1,32 @@
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace LibTmux.Mcp;
+
+/// Refuses a serialized resource result that exceeds policy.
+internal static class ResourceResponseBudgetFilter
+{
+ /// Builds the resource-response budget filter.
+ internal static McpRequestFilter Create(
+ ServerPolicy policy)
+ {
+ ArgumentNullException.ThrowIfNull(policy);
+ return next => async (request, cancellationToken) =>
+ {
+ ReadResourceResult result = await next(request, cancellationToken)
+ .ConfigureAwait(false);
+ int applicationBudget = policy.MaxBytes - Utf8JsonBudget.ProtocolMetadataReserve;
+ if (applicationBudget > 0
+ && Utf8JsonBudget.Fits(result, applicationBudget, ToolJson.Options))
+ {
+ return result;
+ }
+
+ throw new McpException(
+ $"The resource response exceeded this server's {policy.MaxBytes} UTF-8 byte "
+ + "limit. Read a narrower resource or raise "
+ + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server.");
+ };
+ }
+}
diff --git a/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs b/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs
index 33f31b6..6aa600d 100644
--- a/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs
+++ b/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs
@@ -37,6 +37,7 @@ internal static McpRequestFilter Create()
next => async (request, cancellationToken) =>
{
string tool = request.Params?.Name ?? "a tmux tool";
+ bool mayModify = ToolMetadata.MayModify(request, tool);
ILogger logger = request.Services?.GetService()
?.CreateLogger(nameof(ToolFailureFilter))
?? NullLogger.Instance;
@@ -61,6 +62,7 @@ internal static McpRequestFilter Create()
logger,
tool,
retried,
+ mayModify,
"The tmux server was restarted and the retry failed too. "
+ "Call tmux_list_servers to see what is running now.");
}
@@ -71,6 +73,7 @@ internal static McpRequestFilter Create()
logger,
tool,
error,
+ mayModify,
"This tmux is too old for that operation. "
+ "Call tmux_server_info to see which version is running.");
}
@@ -80,13 +83,23 @@ internal static McpRequestFilter Create()
logger,
tool,
error,
+ mayModify,
"That session, window or pane no longer exists. "
+ "Call tmux_hierarchy to see what does.");
}
catch (TmuxCommandException error)
{
// tmux's own message is the most specific thing anybody has.
- return Failure(logger, tool, error, $"tmux refused the command: {error.Message}");
+ return Failure(
+ logger,
+ tool,
+ error,
+ mayModify,
+ $"tmux refused the command: {error.Message}");
+ }
+ catch (TmuxOperationCanceledException error) when (error.CommandMayHaveExecuted)
+ {
+ return Failure(logger, tool, error, mayModify, error.Message);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
@@ -94,13 +107,14 @@ internal static McpRequestFilter Create()
logger,
tool,
null,
+ mayModify,
"The operation ran out of time. Nothing was rolled back — whatever "
- + "was started is still running in its pane. Read the pane before "
- + "trying again, so you do not start it twice.");
+ + "was started is still running in its pane. Do not retry until you "
+ + "have read the pane, so you do not start it twice.");
}
catch (LibTmuxException error)
{
- return Failure(logger, tool, error, error.Message);
+ return Failure(logger, tool, error, mayModify, error.Message);
}
catch (Exception error) when (error is not OperationCanceledException)
{
@@ -109,6 +123,7 @@ internal static McpRequestFilter Create()
logger,
tool,
error,
+ mayModify,
$"{error.Message} This is unexpected — check the server's log on "
+ "standard error before retrying, because retrying unchanged will "
+ "most likely fail the same way.");
@@ -126,6 +141,7 @@ private static CallToolResult Failure(
ILogger logger,
string tool,
Exception? error,
+ bool mayModify,
string advice)
{
if (error is not null)
@@ -136,7 +152,61 @@ private static CallToolResult Failure(
return new CallToolResult
{
IsError = true,
- Content = [new TextContentBlock { Text = $"{tool} failed. {advice}" }],
+ Content =
+ [
+ new TextContentBlock
+ {
+ Text = $"{tool} failed. {ActionableAdvice(tool, error, mayModify, advice)}",
+ },
+ ],
};
}
+
+ internal static string ActionableAdvice(
+ string tool,
+ Exception? error,
+ bool mayModify,
+ string advice)
+ {
+ if (TryPasteCleanup(error, out string? buffer))
+ {
+ return $"The paste failed, and temporary tmux buffer {buffer} may still "
+ + "contain the pasted text because cleanup failed. Do not retry the paste. "
+ + "Inspect with tmux_list_buffers, then ask the operator to remove that "
+ + $"exact buffer with tmux delete-buffer -b {buffer}.";
+ }
+
+ bool mayHaveActed = error is TmuxOperationCanceledException cancellation
+ && cancellation.CommandMayHaveExecuted
+ || error is LibTmuxException tmux
+ && tmux.Dispatch != TmuxDispatchState.NotDispatched;
+ if (!mayModify || !mayHaveActed)
+ {
+ return advice;
+ }
+
+ string recovery = string.Equals(tool, "tmux_start_job", StringComparison.Ordinal)
+ ? " Call tmux_list_jobs now; any possibly started command has a retained handle."
+ : " Inspect tmux state first.";
+ return advice
+ + " tmux may have acted before the failure. Do not retry this operation."
+ + recovery;
+ }
+
+ private static bool TryPasteCleanup(Exception? error, out string? buffer)
+ {
+ buffer = null;
+ if (error?.Data[WriteTools.PasteBufferCleanupFailureDataKey] is not Exception
+ || error.Data[WriteTools.PasteBufferCleanupBufferDataKey] is not string candidate
+ || candidate.Length is < 1 or > 64
+ || !candidate.StartsWith("libtmux_mcp_", StringComparison.Ordinal)
+ || candidate.Any(static character =>
+ !char.IsAsciiLetterOrDigit(character) && character != '_'))
+ {
+ return false;
+ }
+
+ buffer = candidate;
+ return true;
+ }
}
diff --git a/src/LibTmux.Mcp/Filters/ToolMetadata.cs b/src/LibTmux.Mcp/Filters/ToolMetadata.cs
new file mode 100644
index 0000000..b499727
--- /dev/null
+++ b/src/LibTmux.Mcp/Filters/ToolMetadata.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace LibTmux.Mcp;
+
+internal static class ToolMetadata
+{
+ internal static bool MayModify(
+ RequestContext request,
+ string tool)
+ {
+ McpServerOptions? options = request.Services
+ ?.GetService>()?.Value;
+ McpServerTool? registered = options?.ToolCollection?
+ .FirstOrDefault(candidate => string.Equals(
+ candidate.ProtocolTool.Name,
+ tool,
+ StringComparison.Ordinal));
+ return registered?.ProtocolTool.Annotations?.ReadOnlyHint != true;
+ }
+}
diff --git a/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs b/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs
new file mode 100644
index 0000000..d4d3509
--- /dev/null
+++ b/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs
@@ -0,0 +1,113 @@
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+namespace LibTmux.Mcp;
+
+/// Bounds a serialized tool result to policy.
+internal static class ToolResponseBudgetFilter
+{
+ /// Builds the response-budget filter.
+ internal static McpRequestFilter Create(
+ ServerPolicy policy)
+ {
+ ArgumentNullException.ThrowIfNull(policy);
+ return next => async (request, cancellationToken) =>
+ {
+ CallToolResult result = await next(request, cancellationToken).ConfigureAwait(false);
+ if (Utf8JsonBudget.FitsToolResult(result, policy.MaxBytes, ToolJson.Options))
+ {
+ return result;
+ }
+
+ if (result.IsError != true
+ && TryReadActionResult(result.StructuredContent, out ActionResult? action)
+ && action is not null)
+ {
+ return CompletedAction(action);
+ }
+
+ string message = result.IsError == true
+ ? OversizedError(request, policy.MaxBytes)
+ : $"The tool response exceeded this server's {policy.MaxBytes} UTF-8 "
+ + "byte limit. Narrow the target or result count, or raise "
+ + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server.";
+ return new CallToolResult
+ {
+ IsError = true,
+ Content =
+ [
+ new TextContentBlock
+ {
+ Text = message,
+ },
+ ],
+ };
+ };
+ }
+
+ private static string OversizedError(
+ RequestContext request,
+ int maxBytes)
+ {
+ string tool = request.Params?.Name ?? "a tmux tool";
+ return ToolMetadata.MayModify(request, tool)
+ ? $"The tool failed, and its detailed error exceeded this server's "
+ + $"{maxBytes} UTF-8 byte limit. tmux may have acted. Do not retry; "
+ + "inspect tmux state first."
+ : $"The read failed, and its detailed error exceeded this server's "
+ + $"{maxBytes} UTF-8 byte limit. Narrow the target or raise "
+ + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server.";
+ }
+
+ private static bool TryReadActionResult(
+ JsonElement? structuredContent,
+ out ActionResult? action)
+ {
+ action = null;
+ if (structuredContent is not JsonElement structured
+ || structured.ValueKind != JsonValueKind.Object
+ || !structured.TryGetProperty("changed", out JsonElement changed)
+ || changed.ValueKind != JsonValueKind.String)
+ {
+ return false;
+ }
+
+ try
+ {
+ action = structured.Deserialize(ToolJson.Options);
+ return action is not null;
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+ }
+
+ private static CallToolResult CompletedAction(ActionResult original)
+ {
+ var acknowledgement = new ActionResult(
+ "The action completed, but its detailed acknowledgement exceeded the "
+ + "server response limit. Do not retry it; inspect tmux state first.",
+ PaneId: ValidPaneId(original.PaneId),
+ WindowId: ValidWindowId(original.WindowId),
+ SessionId: ValidSessionId(original.SessionId));
+ JsonElement structured = JsonSerializer.SerializeToElement(
+ acknowledgement,
+ ToolJson.Options);
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = structured.GetRawText() }],
+ StructuredContent = structured,
+ };
+ }
+
+ private static string? ValidPaneId(string? value) =>
+ PaneId.TryParse(value, out PaneId id) ? id.ToString() : null;
+
+ private static string? ValidWindowId(string? value) =>
+ WindowId.TryParse(value, out WindowId id) ? id.ToString() : null;
+
+ private static string? ValidSessionId(string? value) =>
+ SessionId.TryParse(value, out SessionId id) ? id.ToString() : null;
+}
diff --git a/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs b/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs
new file mode 100644
index 0000000..0e623cc
--- /dev/null
+++ b/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs
@@ -0,0 +1,383 @@
+using System.Buffers;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using ModelContextProtocol.Protocol;
+
+namespace LibTmux.Mcp;
+
+/// Checks a JSON value against a UTF-8 byte ceiling without retaining it.
+internal static class Utf8JsonBudget
+{
+ private static readonly ConditionalWeakTable
+ StructuredEnvelopeSizes = new();
+ private static readonly ConditionalWeakTable
+ JsonEncodingProfiles = new();
+
+ // MCP 2.2 appends server identity and may wrap a result in task metadata
+ // after application filters run, so every result keeps room for both.
+ internal const int ProtocolMetadataReserve = 512;
+
+ /// Answers whether the serialized value fits the ceiling.
+ internal static bool Fits(T value, int maxBytes, JsonSerializerOptions options)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
+ ArgumentNullException.ThrowIfNull(options);
+
+ try
+ {
+ using var sink = new LimitedWriteStream(maxBytes);
+ JsonSerializer.Serialize(sink, value, options);
+ return true;
+ }
+ catch (BudgetExceededException)
+ {
+ return false;
+ }
+ }
+
+ /// Counts the serialized UTF-8 bytes without retaining them.
+ internal static int GetByteCount(T value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ using var sink = new LimitedWriteStream(int.MaxValue);
+ JsonSerializer.Serialize(sink, value, options);
+ return checked((int)sink.Length);
+ }
+
+ /// Budgets the complete MCP result produced for a structured return value.
+ internal static int GetStructuredToolResultByteCount(
+ T value,
+ JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ using var sink = new JsonFragmentCountingStream(options);
+ JsonSerializer.Serialize(sink, value, options);
+ return checked(
+ GetStructuredEnvelopeSize(options)
+ + sink.RawLength
+ + sink.EmbeddedStringContentLength
+ + ProtocolMetadataReserve);
+ }
+
+ /// Answers whether a result fits after protocol metadata is appended.
+ internal static bool FitsToolResult(
+ CallToolResult value,
+ int maxBytes,
+ JsonSerializerOptions options)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(
+ maxBytes,
+ ProtocolMetadataReserve);
+ return Fits(value, maxBytes - ProtocolMetadataReserve, options);
+ }
+
+ /// Counts one JSON fragment in both copies of a structured tool result.
+ internal static int GetStructuredJsonFragmentByteCount(
+ T value,
+ JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ using var sink = new JsonFragmentCountingStream(options);
+ JsonSerializer.Serialize(sink, value, options);
+ return checked(sink.RawLength + sink.EmbeddedStringContentLength);
+ }
+
+ /// Counts a bounded-text fragment without materializing large JSON strings.
+ internal static int GetStructuredJsonFragmentByteCount(
+ BoundedText value,
+ JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ var skeleton = new BoundedText(
+ [],
+ value.Truncated,
+ value.DroppedLines,
+ value.DroppedBytes);
+ int bytes = GetStructuredJsonFragmentByteCount(skeleton, options);
+ if (value.Lines.Count == 0)
+ {
+ return bytes;
+ }
+
+ int embeddedQuoteBytes = MeasureJsonString("\"", options).RawContentLength;
+ for (int index = 0; index < value.Lines.Count; index++)
+ {
+ JsonStringMetrics line = MeasureJsonString(value.Lines[index], options);
+ bytes = checked(
+ bytes
+ + 2
+ + line.RawContentLength
+ + (2 * embeddedQuoteBytes)
+ + line.EmbeddedContentLength
+ + (index > 0 ? 2 : 0));
+ }
+
+ return bytes;
+ }
+
+ /// Counts a matched-line fragment without materializing its text.
+ internal static int GetStructuredJsonFragmentByteCount(
+ MatchedLine value,
+ JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ int skeleton = GetStructuredJsonFragmentByteCount(
+ new MatchedLine(value.Row, string.Empty),
+ options);
+ JsonStringMetrics text = MeasureJsonString(value.Text, options);
+ return checked(skeleton + text.RawContentLength + text.EmbeddedContentLength);
+ }
+
+ /// Counts one string's content in the raw and embedded JSON copies.
+ internal static int GetStructuredJsonStringContentByteCount(
+ string value,
+ JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(options);
+ JsonStringMetrics metrics = MeasureJsonString(value, options);
+ return checked(metrics.RawContentLength + metrics.EmbeddedContentLength);
+ }
+
+ private static int GetStructuredEnvelopeSize(JsonSerializerOptions options) =>
+ StructuredEnvelopeSizes.GetValue(
+ options,
+ static current => new EnvelopeSize(MeasureStructuredEnvelope(current))).Value;
+
+ private static int MeasureStructuredEnvelope(JsonSerializerOptions options)
+ {
+ JsonElement structured = JsonSerializer.SerializeToElement(new { }, options);
+ var result = new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "{}" }],
+ StructuredContent = structured,
+ };
+
+ return checked(GetByteCount(result, options) - 4);
+ }
+
+ private static JsonStringMetrics MeasureJsonString(
+ string value,
+ JsonSerializerOptions options)
+ {
+ JsonEncodingProfile profile = JsonEncodingProfiles.GetValue(
+ options,
+ static current => new JsonEncodingProfile(
+ current.Encoder ?? JavaScriptEncoder.Default));
+ int rawLength = 0;
+ int embeddedLength = 0;
+ ReadOnlySpan remaining = value;
+ while (!remaining.IsEmpty)
+ {
+ JsonStringMetrics rune;
+ int consumed;
+ if (remaining[0] <= 0x7f)
+ {
+ rune = profile.Ascii[remaining[0]];
+ consumed = 1;
+ }
+ else
+ {
+ OperationStatus status = Rune.DecodeFromUtf16(
+ remaining,
+ out Rune decoded,
+ out consumed);
+ if (status != OperationStatus.Done)
+ {
+ decoded = Rune.ReplacementChar;
+ consumed = 1;
+ }
+
+ rune = EncodeRune(decoded, profile.Encoder);
+ }
+
+ rawLength = checked(rawLength + rune.RawContentLength);
+ embeddedLength = checked(embeddedLength + rune.EmbeddedContentLength);
+ remaining = remaining[consumed..];
+ }
+
+ return new JsonStringMetrics(rawLength, embeddedLength);
+ }
+
+ private static JsonStringMetrics EncodeRune(Rune rune, JavaScriptEncoder encoder)
+ {
+ Span source = stackalloc byte[4];
+ Span encoded = stackalloc byte[64];
+ Span embedded = stackalloc byte[384];
+ int sourceLength = rune.EncodeToUtf8(source);
+ OperationStatus first = encoder.EncodeUtf8(
+ source[..sourceLength],
+ encoded,
+ out int firstConsumed,
+ out int firstWritten,
+ isFinalBlock: true);
+ OperationStatus second = encoder.EncodeUtf8(
+ encoded[..firstWritten],
+ embedded,
+ out int secondConsumed,
+ out int secondWritten,
+ isFinalBlock: true);
+ if (first != OperationStatus.Done
+ || firstConsumed != sourceLength
+ || second != OperationStatus.Done
+ || secondConsumed != firstWritten)
+ {
+ throw new InvalidOperationException("The JSON encoder exceeded its rune bound.");
+ }
+
+ return new JsonStringMetrics(firstWritten, secondWritten);
+ }
+
+ private sealed class EnvelopeSize(int value)
+ {
+ internal int Value { get; } = value;
+ }
+
+ private sealed class JsonEncodingProfile
+ {
+ internal JsonEncodingProfile(JavaScriptEncoder encoder)
+ {
+ Encoder = encoder;
+ Ascii = new JsonStringMetrics[128];
+ for (int value = 0; value < Ascii.Length; value++)
+ {
+ Ascii[value] = EncodeRune(new Rune(value), encoder);
+ }
+ }
+
+ internal JsonStringMetrics[] Ascii { get; }
+
+ internal JavaScriptEncoder Encoder { get; }
+ }
+
+ private sealed class LimitedWriteStream(int maxBytes) : Stream
+ {
+ private int _bytesWritten;
+
+ public override bool CanRead => false;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => true;
+
+ public override long Length => _bytesWritten;
+
+ public override long Position
+ {
+ get => _bytesWritten;
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ ArgumentOutOfRangeException.ThrowIfNegative(count);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length - count);
+ Count(count);
+ }
+
+ public override void Write(ReadOnlySpan buffer) => Count(buffer.Length);
+
+ private void Count(int count)
+ {
+ if (count > maxBytes - _bytesWritten)
+ {
+ throw new BudgetExceededException();
+ }
+
+ _bytesWritten += count;
+ }
+ }
+
+ private sealed class JsonFragmentCountingStream : Stream
+ {
+ private readonly int _backslashExpansion;
+ private readonly int _quoteExpansion;
+ private int _embeddedExpansion;
+ private int _rawLength;
+
+ internal JsonFragmentCountingStream(JsonSerializerOptions options)
+ {
+ _quoteExpansion = MeasureJsonString("\"", options).RawContentLength - 1;
+ _backslashExpansion = MeasureJsonString("\\", options).RawContentLength - 1;
+ }
+
+ internal int RawLength => _rawLength;
+
+ internal int EmbeddedStringContentLength => checked(_rawLength + _embeddedExpansion);
+
+ public override bool CanRead => false;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => true;
+
+ public override long Length => _rawLength;
+
+ public override long Position
+ {
+ get => _rawLength;
+ set => throw new NotSupportedException();
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ public override long Seek(long offset, SeekOrigin origin) =>
+ throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ ArgumentNullException.ThrowIfNull(buffer);
+ ArgumentOutOfRangeException.ThrowIfNegative(offset);
+ ArgumentOutOfRangeException.ThrowIfNegative(count);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length - count);
+ Count(buffer.AsSpan(offset, count));
+ }
+
+ public override void Write(ReadOnlySpan buffer) => Count(buffer);
+
+ private void Count(ReadOnlySpan buffer)
+ {
+ _rawLength = checked(_rawLength + buffer.Length);
+ foreach (byte value in buffer)
+ {
+ if (value is (byte)'"' or (byte)'\\')
+ {
+ _embeddedExpansion = checked(
+ _embeddedExpansion
+ + (value == (byte)'"' ? _quoteExpansion : _backslashExpansion));
+ }
+ }
+ }
+ }
+
+ private readonly record struct JsonStringMetrics(
+ int RawContentLength,
+ int EmbeddedContentLength);
+
+ private sealed class BudgetExceededException : Exception;
+}
diff --git a/src/LibTmux.Mcp/Jobs/JobStore.cs b/src/LibTmux.Mcp/Jobs/JobStore.cs
index 13dbe27..8f49f68 100644
--- a/src/LibTmux.Mcp/Jobs/JobStore.cs
+++ b/src/LibTmux.Mcp/Jobs/JobStore.cs
@@ -1,5 +1,8 @@
-using System.Collections.Concurrent;
+using System.Runtime.ExceptionServices;
using System.Runtime.Versioning;
+using System.Text;
+using System.Threading.Channels;
+using LibTmux.Internal;
using Microsoft.Extensions.Logging;
using ModelContextProtocol;
@@ -23,24 +26,37 @@ namespace LibTmux.Mcp;
///
///
[UnsupportedOSPlatform("windows")]
-public sealed class JobStore : IDisposable
+public sealed class JobStore : IDisposable, IAsyncDisposable
{
- private const int MaxRetained = 100;
+ internal const int Capacity = 100;
+ internal const string RecoveryJobIdDataKey = "LibTmux.Mcp.JobId";
- private readonly ConcurrentDictionary _jobs = new(StringComparer.Ordinal);
+ private readonly object _gate = new();
+ private readonly Dictionary _jobs = new(StringComparer.Ordinal);
+ private readonly HashSet _starting = [];
+ private readonly HashSet _operations = [];
private readonly ILogger? _logger;
private readonly CancellationTokenSource _shutdown = new();
+ private Exception? _operationFailure;
+ private Task? _disposeTask;
+ private bool _stopping;
/// Initializes the store.
/// Records how a job ended.
public JobStore(ILogger? logger = null) => _logger = logger;
///
- public void Dispose()
+ public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
+
+ ///
+ public ValueTask DisposeAsync()
{
- // The commands themselves are tmux's; only the watchers stop.
- _shutdown.Cancel();
- _shutdown.Dispose();
+ lock (_gate)
+ {
+ _stopping = true;
+ _disposeTask ??= DisposeCoreAsync();
+ return new ValueTask(_disposeTask);
+ }
}
/// Starts a command and answers a handle for it immediately.
@@ -50,32 +66,71 @@ public void Dispose()
/// Whether to keep the command out of shell history.
/// Cancels sending the command.
/// The job.
- public async Task StartAsync(
+ public Task StartAsync(
+ Server server,
+ Pane pane,
+ string command,
+ bool suppressHistory,
+ CancellationToken cancellationToken) =>
+ StartAsync(
+ server,
+ pane,
+ command,
+ suppressHistory,
+ ServerPolicy.DefaultMaxBytes,
+ cancellationToken);
+
+ internal Task StartAsync(
Server server,
Pane pane,
string command,
bool suppressHistory,
+ int maxCommandBytes,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(pane);
ArgumentException.ThrowIfNullOrWhiteSpace(command);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCommandBytes);
+ RequireOwnership(server, pane);
+
+ int commandBytes = Encoding.UTF8.GetByteCount(command);
+ if (commandBytes > maxCommandBytes)
+ {
+ throw new McpException(
+ $"The command is {commandBytes} UTF-8 bytes; the job input ceiling is "
+ + $"{maxCommandBytes}. Put a longer script in a file and start that file instead.");
+ }
- // The handle IS the run token's id, so a caller holding a job id can be
- // matched against the bookkeeping lines that job left in its pane.
WriteTools.RunToken token = WriteTools.RunToken.Create();
- Job job = new(token.Id, pane.Id.ToString(), command, token);
- Forget();
- _jobs[job.JobId] = job;
-
- await WriteTools
- .SendRunPayloadAsync(server, pane, command, token, suppressHistory, cancellationToken)
- .ConfigureAwait(false);
-
- // Watching is deliberately detached: the point of a job is that the
- // caller does not wait, so nothing here may be awaited by the tool call.
- job.Watcher = Task.Run(() => WatchAsync(server, pane, job), CancellationToken.None);
- return job.Describe();
+ var job = new StoredJob(token.Id, server, pane, commandBytes, token);
+ job.RequireToolResultsFit(maxCommandBytes);
+ var reservation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_stopping, this);
+ ForgetLocked();
+ if (_jobs.Count + _starting.Count >= Capacity)
+ {
+ throw new McpException(
+ $"This MCP server is already tracking {Capacity} live job operations. "
+ + "Wait for one of their tmux watchers to finish before starting another; "
+ + "cancellation retains its slot until then.");
+ }
+
+ _starting.Add(job);
+ TrackLocked(reservation.Task);
+ }
+
+ return StartCoreAsync(
+ server,
+ pane,
+ command,
+ suppressHistory,
+ job,
+ reservation,
+ cancellationToken);
}
/// Answers what a job is doing.
@@ -84,57 +139,172 @@ await WriteTools
/// No job has that handle.
public JobInfo Get(string jobId) => Require(jobId).Describe();
- /// Answers every job this server still remembers.
+ /// Answers the jobs that fit within one response.
+ /// The complete UTF-8 response ceiling, including protocol reserve.
/// The jobs, most recently started first.
- public IReadOnlyList List() =>
- [.. _jobs.Values.OrderByDescending(job => job.StartedAt).Select(job => job.Describe())];
+ /// The envelope cannot fit within the byte ceiling.
+ public JobList List(int maxBytes = ServerPolicy.DefaultMaxBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
+ StoredJob[] jobs;
+ lock (_gate)
+ {
+ jobs = [.. _jobs.Values.OrderByDescending(job => job.StartedAt)];
+ }
- /// Reads the cursor a job's output was last collected from.
- /// The handle.
- /// The cursor, or null when nothing has been collected yet.
- public string? CursorFor(string jobId) => Require(jobId).Cursor;
+ JobInfo[] available = [.. jobs.Select(job => job.Describe())];
+ JobList empty = new([], available.Length, available.Length > 0);
+ int envelopeBytes = Utf8JsonBudget.GetStructuredToolResultByteCount(
+ empty,
+ ToolJson.Options);
+ if (envelopeBytes > maxBytes)
+ {
+ throw new McpException(
+ $"The job-list response needs at least {envelopeBytes} UTF-8 bytes; "
+ + $"the configured ceiling is {maxBytes}.");
+ }
- /// Records where a job's output has now been collected to.
- /// The handle.
- /// The new cursor.
- public void Advance(string jobId, string cursor) => Require(jobId).Cursor = cursor;
+ var kept = new List(available.Length);
+ foreach (JobInfo candidate in available)
+ {
+ JobList proposed = new(
+ [.. kept, candidate],
+ available.Length,
+ kept.Count + 1 < available.Length);
+ if (Utf8JsonBudget.GetStructuredToolResultByteCount(
+ proposed,
+ ToolJson.Options) > maxBytes)
+ {
+ break;
+ }
- /// Interrupts a job and stops watching it.
- /// The pane it runs in.
+ kept.Add(candidate);
+ }
+
+ return new JobList([.. kept], available.Length, kept.Count < available.Length);
+ }
+
+ /// Interrupts a job on the endpoint that started it.
/// The handle.
+ /// An optional assertion about the originating socket.
/// Cancels sending the interrupt.
/// The job.
- ///
- /// Interrupting means sending the pane a C-c, which is a request
- /// rather than a guarantee: a program that ignores it keeps running, and
- /// the job then ends up reported as cancelled while the pane is still busy.
- /// The pane's current command is the honest check.
- ///
- public async Task CancelAsync(
- Pane pane,
+ public Task CancelAsync(
string jobId,
+ string? socketName = null,
+ CancellationToken cancellationToken = default) =>
+ Resolve(jobId, socketName).CancelAsync(cancellationToken);
+
+ internal StoredJob Resolve(string jobId, string? socketName)
+ {
+ StoredJob job = Require(jobId);
+ job.RequireSocket(socketName);
+ return job;
+ }
+
+ private async Task StartCoreAsync(
+ Server server,
+ Pane pane,
+ string command,
+ bool suppressHistory,
+ StoredJob job,
+ TaskCompletionSource reservation,
CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(pane);
- Job job = Require(jobId);
- if (job.State == JobState.Running)
+ using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken,
+ _shutdown.Token);
+ bool dispatchAttempted = false;
+ bool published = false;
+ try
{
- await pane.SendKeysAsync(
- new SendKeysRequest(text: "C-c", enter: false, literal: false),
- cancellationToken)
+ PaneRead baseline = await PaneReader
+ .ReadVisibleAsync(pane, null, linked.Token)
.ConfigureAwait(false);
- job.Finish(JobState.Cancelled, null);
+ job.SetInitialCursor(
+ TailCursor.Build(pane, baseline.State, baseline.CursorRows).Encode());
+
+ dispatchAttempted = true;
+ await WriteTools
+ .SendRunPayloadAsync(
+ server,
+ pane,
+ command,
+ job.Token,
+ suppressHistory,
+ WriteTools.JobStatusMarkerLifetime,
+ linked.Token)
+ .ConfigureAwait(false);
+
+ Publish(job, server, pane);
+ published = true;
+ return job.Describe();
}
+ catch (TmuxOperationCanceledException error) when (
+ dispatchAttempted && error.CommandMayHaveExecuted)
+ {
+ Publish(job, server, pane);
+ published = true;
+ error.Data[RecoveryJobIdDataKey] = job.JobId;
+ throw;
+ }
+ catch (LibTmuxException error) when (
+ dispatchAttempted && error.Dispatch != TmuxDispatchState.NotDispatched)
+ {
+ Publish(job, server, pane);
+ published = true;
+ error.Data[RecoveryJobIdDataKey] = job.JobId;
+ throw;
+ }
+ catch
+ {
+ if (!published)
+ {
+ RejectStart(job);
+ }
+
+ throw;
+ }
+ finally
+ {
+ reservation.TrySetResult();
+ }
+ }
- return job.Describe();
+ private void Publish(StoredJob job, Server server, Pane pane)
+ {
+ Task watcher = WatchAsync(server, pane, job);
+ job.Watcher = watcher;
+ lock (_gate)
+ {
+ if (!_starting.Remove(job))
+ {
+ throw new InvalidOperationException("The job start reservation no longer exists.");
+ }
+
+ _jobs.Add(job.JobId, job);
+ TrackLocked(watcher);
+ }
+ }
+
+ private void RejectStart(StoredJob job)
+ {
+ job.TryFinish(JobState.Lost, null);
+ lock (_gate)
+ {
+ _starting.Remove(job);
+ }
}
- private Job Require(string jobId)
+ private StoredJob Require(string jobId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
- if (_jobs.TryGetValue(jobId.Trim(), out Job? job))
+ lock (_gate)
{
- return job;
+ if (_jobs.TryGetValue(jobId.Trim(), out StoredJob? job))
+ {
+ return job;
+ }
}
throw new McpException(
@@ -143,26 +313,53 @@ private Job Require(string jobId)
+ "running in its pane.");
}
- private void Forget()
+ private static void RequireOwnership(Server server, Pane pane)
{
- if (_jobs.Count < MaxRetained)
+ Server owner;
+ try
{
- return;
+ owner = pane.Server;
+ }
+ catch (IncompleteSnapshotException error)
+ {
+ throw new McpException(
+ "The job pane has no exact server ownership. Resolve it from the server "
+ + "that will start the command.",
+ error);
}
- // Only finished jobs are dropped: a running one still has a result
- // somebody is waiting for.
- foreach (Job stale in _jobs.Values
- .Where(job => job.State != JobState.Running)
- .OrderBy(job => job.EndedAt ?? job.StartedAt)
- .Take(_jobs.Count - MaxRetained + 1))
+ string? serverEndpoint = server.Connection?.GetEndpointFingerprint();
+ string? paneEndpoint = owner.Connection?.GetEndpointFingerprint();
+ if (serverEndpoint is null
+ || paneEndpoint is null
+ || !string.Equals(serverEndpoint, paneEndpoint, StringComparison.Ordinal)
+ || server.Generation != pane.Generation
+ || owner.Generation != pane.Generation)
{
- _jobs.TryRemove(stale.JobId, out _);
+ throw new McpException(
+ $"Pane {pane.Id} belongs to a different tmux endpoint or server generation. "
+ + "Resolve the pane from the same server passed to StartAsync.");
+ }
+ }
+
+ private void ForgetLocked()
+ {
+ foreach (StoredJob stale in _jobs.Values
+ .Where(job => job.CanReleaseSlot)
+ .OrderBy(job => job.EndedAt ?? job.StartedAt))
+ {
+ if (_jobs.Count + _starting.Count < Capacity)
+ {
+ break;
+ }
+
+ _jobs.Remove(stale.JobId);
}
}
- private async Task WatchAsync(Server server, Pane pane, Job job)
+ private async Task WatchAsync(Server server, Pane pane, StoredJob job)
{
+ Exception? unexpected = null;
try
{
await server.WaitForAsync(
@@ -173,69 +370,359 @@ await server.WaitForAsync(
int? status = await WriteTools
.ReadStatusAsync(pane, job.Token, _shutdown.Token)
.ConfigureAwait(false);
- job.Finish(JobState.Exited, status);
+ job.TryFinish(JobState.Exited, status);
}
- catch (OperationCanceledException)
+ catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
{
- // Shutdown. The command is tmux's and carries on without us.
+ // The command belongs to tmux and survives this bookkeeping store.
}
catch (LibTmuxException)
{
- job.Finish(JobState.Lost, null);
+ job.TryFinish(JobState.Lost, null);
+ }
+ catch (Exception error)
+ {
+ unexpected = error;
+ job.TryFinish(JobState.Lost, null);
}
- if (_logger is not null)
+ if (_logger is not null && unexpected is not null)
+ {
+ Log.JobWatcherFailed(_logger, unexpected, job.JobId, job.PaneId);
+ }
+
+ if (_logger is not null && job.State != JobState.Running)
{
Log.JobEnded(_logger, job.JobId, job.PaneId, job.State);
}
}
- private sealed class Job(
- string jobId,
- string paneId,
- string command,
- WriteTools.RunToken token)
+ private void TrackLocked(Task operation)
+ {
+ foreach (Task completed in _operations
+ .Where(static tracked => tracked.IsCompleted)
+ .ToArray())
+ {
+ RecordFailureLocked(completed);
+ _operations.Remove(completed);
+ }
+
+ _operations.Add(operation);
+ }
+
+ private void RecordFailureLocked(Task operation)
{
- internal string JobId { get; } = jobId;
+ if (_operationFailure is null && operation.IsFaulted)
+ {
+ _operationFailure = operation.Exception;
+ }
+ }
- internal string PaneId { get; } = paneId;
+ private async Task DisposeCoreAsync()
+ {
+ Exception? failure;
+ lock (_gate)
+ {
+ failure = _operationFailure;
+ }
- internal WriteTools.RunToken Token { get; } = token;
+ try
+ {
+ try
+ {
+ _shutdown.Cancel();
+ }
+ catch (Exception error)
+ {
+ failure ??= error;
+ }
- internal DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow;
+ while (true)
+ {
+ Task[] pending;
+ lock (_gate)
+ {
+ pending = [.. _operations];
+ }
+
+ if (pending.Length == 0)
+ {
+ break;
+ }
+
+ Task all = Task.WhenAll(pending);
+ try
+ {
+ await all.ConfigureAwait(false);
+ }
+ catch (Exception error)
+ {
+ failure ??= all.Exception ?? error;
+ }
+
+ lock (_gate)
+ {
+ foreach (Task completed in pending)
+ {
+ RecordFailureLocked(completed);
+ _operations.Remove(completed);
+ }
+
+ failure ??= _operationFailure;
+ }
+ }
+ }
+ finally
+ {
+ _shutdown.Dispose();
+ }
- internal DateTimeOffset? EndedAt { get; private set; }
+ if (failure is not null)
+ {
+ ExceptionDispatchInfo.Capture(failure).Throw();
+ }
+ }
+
+ internal sealed class StoredJob
+ {
+ private static readonly Progress Running = new(JobState.Running, null, null);
+
+ private readonly Channel _cancelGate = Gate();
+ private readonly Channel _outputGate = Gate();
+ private readonly TaskCompletionSource _terminal = new(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly int _commandBytes;
+ private readonly string _endpointFingerprint;
+ private readonly string? _socketName;
+ private readonly string? _socketPath;
+ private Progress _progress = Running;
+ private string? _cursor;
+
+ internal StoredJob(
+ string jobId,
+ Server server,
+ Pane pane,
+ int commandBytes,
+ WriteTools.RunToken token)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(jobId);
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(pane);
+ ArgumentOutOfRangeException.ThrowIfNegative(commandBytes);
+
+ JobId = jobId;
+ Server = server;
+ Pane = pane;
+ PaneId = pane.Id.ToString();
+ ServerGeneration = pane.Generation;
+ _commandBytes = commandBytes;
+ TmuxConnection connection = pane.Server.Connection
+ ?? throw new IncompleteSnapshotException("connection", SnapshotDepth.Server);
+ _endpointFingerprint = connection.GetEndpointFingerprint();
+ Token = token;
+ StartedAt = DateTimeOffset.UtcNow;
+
+ (_socketName, _socketPath) = connection.ResolvedSocket;
+ }
+
+ internal string JobId { get; }
+
+ internal string PaneId { get; }
+
+ internal Server Server { get; }
+
+ internal Pane Pane { get; }
+
+ internal ServerGeneration ServerGeneration { get; }
+
+ internal WriteTools.RunToken Token { get; }
- internal JobState State { get; private set; } = JobState.Running;
+ internal DateTimeOffset StartedAt { get; }
- internal int? ExitStatus { get; private set; }
+ internal DateTimeOffset? EndedAt => Volatile.Read(ref _progress).EndedAt;
- internal string? Cursor { get; set; }
+ internal JobState State => Volatile.Read(ref _progress).State;
internal Task? Watcher { get; set; }
- internal void Finish(JobState state, int? exitStatus)
+ internal Task Terminal => _terminal.Task;
+
+ internal bool CanReleaseSlot =>
+ State != JobState.Running && Watcher is { IsCompleted: true };
+
+ internal bool TryFinish(JobState state, int? exitStatus)
+ {
+ if (state == JobState.Running)
+ {
+ throw new ArgumentOutOfRangeException(nameof(state));
+ }
+
+ var terminal = new Progress(state, exitStatus, DateTimeOffset.UtcNow);
+ bool changed = ReferenceEquals(
+ Interlocked.CompareExchange(ref _progress, terminal, Running),
+ Running);
+ if (changed)
+ {
+ _terminal.TrySetResult();
+ }
+
+ return changed;
+ }
+
+ internal async Task CancelAsync(CancellationToken cancellationToken)
{
- if (State != JobState.Running)
+ _ = await _cancelGate.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (State == JobState.Running)
+ {
+ await Pane.SendKeysAsync(
+ new SendKeysRequest(text: "C-c", enter: false, literal: false),
+ cancellationToken)
+ .ConfigureAwait(false);
+ TryFinish(JobState.Cancelled, null);
+ }
+
+ return Describe();
+ }
+ finally
+ {
+ _cancelGate.Writer.TryWrite(0);
+ }
+ }
+
+ internal async ValueTask AcquireOutputAsync(
+ CancellationToken cancellationToken)
+ {
+ _ = await _outputGate.Reader.ReadAsync(cancellationToken).ConfigureAwait(false);
+ return new OutputLease(this);
+ }
+
+ internal void SetInitialCursor(string cursor)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(cursor);
+ if (Interlocked.CompareExchange(ref _cursor, cursor, null) is not null)
+ {
+ throw new InvalidOperationException("The job output baseline was already set.");
+ }
+ }
+
+ internal void RequireSocket(string? suppliedSocketName)
+ {
+ if (string.IsNullOrWhiteSpace(suppliedSocketName))
{
return;
}
- State = state;
- ExitStatus = exitStatus;
- EndedAt = DateTimeOffset.UtcNow;
+ string supplied = suppliedSocketName.Trim();
+ if (_socketName is not null
+ && string.Equals(supplied, _socketName, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ string endpoint = _socketPath is not null
+ ? $"socket path '{_socketPath}'"
+ : _socketName is not null
+ ? $"socket '{_socketName}'"
+ : "its recorded endpoint";
+ throw new McpException(
+ $"Job '{JobId}' belongs to {endpoint}, not supplied socket '{supplied}'. "
+ + "Omit socketName to use the job's recorded endpoint.");
}
- internal JobInfo Describe() => new(
- JobId: JobId,
- PaneId: PaneId,
- Command: command,
- State: State,
- ExitStatus: ExitStatus,
- StartedAt: StartedAt,
- EndedAt: EndedAt,
- ElapsedSeconds: Math.Round(
- ((EndedAt ?? DateTimeOffset.UtcNow) - StartedAt).TotalSeconds,
- 3));
+ internal JobInfo Describe()
+ {
+ Progress progress = Volatile.Read(ref _progress);
+ double elapsedSeconds = Math.Round(
+ ((progress.EndedAt ?? DateTimeOffset.UtcNow) - StartedAt).TotalSeconds,
+ 3);
+ return CreateDescription(progress, elapsedSeconds);
+ }
+
+ internal void RequireToolResultsFit(int maxBytes)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
+ string endpoint = _socketPath ?? _socketName ?? string.Empty;
+ int endpointBytes = Encoding.UTF8.GetByteCount(endpoint);
+ if (endpointBytes > maxBytes)
+ {
+ throw new McpException(
+ $"The job handle response cannot fit because its endpoint identity is "
+ + $"{endpointBytes} UTF-8 bytes; the configured ceiling is {maxBytes}. "
+ + $"Use a shorter socket name or raise {ServerPolicy.MaxBytesVariable} "
+ + "and restart the MCP server.");
+ }
+
+ JobInfo worstCase = CreateDescription(
+ new Progress(JobState.Cancelled, int.MinValue, DateTimeOffset.MaxValue),
+ double.MaxValue);
+ int requiredBytes = Utf8JsonBudget.GetStructuredToolResultByteCount(
+ worstCase,
+ ToolJson.Options);
+ if (requiredBytes > maxBytes)
+ {
+ throw new McpException(
+ $"The job handle response needs at least {requiredBytes} UTF-8 bytes; "
+ + $"the configured ceiling is {maxBytes}. Use a shorter socket name or "
+ + $"raise {ServerPolicy.MaxBytesVariable} and restart the MCP server.");
+ }
+ }
+
+ private JobInfo CreateDescription(Progress progress, double elapsedSeconds) =>
+ new(
+ JobId: JobId,
+ PaneId: PaneId,
+ SocketName: _socketName,
+ SocketPath: _socketPath,
+ EndpointFingerprint: _endpointFingerprint,
+ ServerGeneration: ServerGeneration,
+ CommandBytes: _commandBytes,
+ State: progress.State,
+ ExitStatus: progress.ExitStatus,
+ StartedAt: StartedAt,
+ EndedAt: progress.EndedAt,
+ ElapsedSeconds: elapsedSeconds);
+
+ private sealed record Progress(
+ JobState State,
+ int? ExitStatus,
+ DateTimeOffset? EndedAt);
+
+ private static Channel Gate()
+ {
+ Channel gate = Channel.CreateBounded(new BoundedChannelOptions(1)
+ {
+ FullMode = BoundedChannelFullMode.Wait,
+ SingleReader = false,
+ SingleWriter = false,
+ });
+ gate.Writer.TryWrite(0);
+ return gate;
+ }
+
+ internal sealed class OutputLease : IDisposable
+ {
+ private StoredJob? _job;
+
+ internal OutputLease(StoredJob job) => _job = job;
+
+ internal string? Cursor => RequireJob()._cursor;
+
+ internal void Advance(string cursor)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(cursor);
+ RequireJob()._cursor = cursor;
+ }
+
+ public void Dispose()
+ {
+ StoredJob? job = Interlocked.Exchange(ref _job, null);
+ job?._outputGate.Writer.TryWrite(0);
+ }
+
+ private StoredJob RequireJob() =>
+ _job ?? throw new ObjectDisposedException(nameof(OutputLease));
+ }
}
}
diff --git a/src/LibTmux.Mcp/McpServerComposition.cs b/src/LibTmux.Mcp/McpServerComposition.cs
index 044615f..d13caef 100644
--- a/src/LibTmux.Mcp/McpServerComposition.cs
+++ b/src/LibTmux.Mcp/McpServerComposition.cs
@@ -1,6 +1,7 @@
using System.Runtime.Versioning;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
@@ -47,6 +48,8 @@ public static IMcpServerBuilder Add(
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ var taskStore = new BoundedMcpTaskStore();
+ services.AddSingleton(_ => new SubscriptionAdmission());
IMcpServerBuilder builder = services
.AddMcpServer(options =>
@@ -58,10 +61,15 @@ public static IMcpServerBuilder Add(
};
options.ServerInstructions = ServerInstructions.Compose(policy, callerPaneId);
})
- .WithTools()
+ .WithTools(ToolJson.Options)
.WithResources()
.WithPrompts()
- .WithRequestFilters(filters => filters.AddCallToolFilter(ToolFailureFilter.Create()))
+ .WithRequestFilters(filters =>
+ {
+ filters.AddCallToolFilter(next =>
+ ToolResponseBudgetFilter.Create(policy)(ToolFailureFilter.Create()(next)));
+ filters.AddReadResourceFilter(ResourceResponseBudgetFilter.Create(policy));
+ })
// A subscription is what turns the hierarchy from something a
// client re-reads on a timer into something that tells it when to.
@@ -76,6 +84,7 @@ public static IMcpServerBuilder Add(
await scope.GetRequiredService()
.SubscribeAsync(
uri,
+ notify,
async changed =>
{
foreach (string each in changed)
@@ -101,37 +110,36 @@ await scope.GetRequiredService()
&& context.Services is IServiceProvider scope)
{
await scope.GetRequiredService()
- .UnsubscribeAsync(uri)
+ .UnsubscribeAsync(uri, context.Server)
.ConfigureAwait(false);
}
return new EmptyResult();
})
- // The revision that replaced resources/subscribe answers listen
- // itself, granting the subscription without telling the
- // application — so a client on a current revision would subscribe
- // and hear nothing. Owning the stream is what closes that.
+ // The current revision grants listen without notifying the application;
+ // owning the stream is what starts the watcher that emits its events.
.WithSubscriptionsListenHandler(SubscriptionStream.Create())
- // The protocol's own answer to a call that waits. A client that
- // declares the extension gets a handle back at once and collects
- // later; one that has not keeps the blocking call it had.
+ // Task-capable clients may collect a wait later; other clients block.
.WithTasks(
- new InMemoryMcpTaskStore(),
+ taskStore,
tasks => tasks.ExecutionModeSelector = TaskCapableTools.Select);
+ services.AddSingleton>(
+ new BoundedMcpTaskCancellationOptions(taskStore));
+
// Registration, not filtering. A tool the operator's tier does not
// allow never reaches the model's list, so it cannot be called by name,
// guessed at, or argued for.
if (policy.Allows(SafetyTier.Mutating))
{
- builder.WithTools();
+ builder.WithTools(ToolJson.Options);
}
if (policy.Allows(SafetyTier.Destructive))
{
- builder.WithTools();
+ builder.WithTools(ToolJson.Options);
}
return builder;
diff --git a/src/LibTmux.Mcp/Policy/TaskCapableTools.cs b/src/LibTmux.Mcp/Policy/TaskCapableTools.cs
index e33fa65..31bcefd 100644
--- a/src/LibTmux.Mcp/Policy/TaskCapableTools.cs
+++ b/src/LibTmux.Mcp/Policy/TaskCapableTools.cs
@@ -30,7 +30,6 @@ internal static class TaskCapableTools
/// The tools whose whole job is to wait for something.
private static readonly HashSet Waiting = new(StringComparer.Ordinal)
{
- "tmux_run",
"tmux_wait_for_text",
"tmux_wait_for_channel",
"tmux_job",
diff --git a/src/LibTmux.Mcp/Prompts/RecipePrompts.cs b/src/LibTmux.Mcp/Prompts/RecipePrompts.cs
index d02a0b2..7463fff 100644
--- a/src/LibTmux.Mcp/Prompts/RecipePrompts.cs
+++ b/src/LibTmux.Mcp/Prompts/RecipePrompts.cs
@@ -23,19 +23,19 @@ public static string RunAndReport(
[Description("The shell command to run.")] string command,
[Description("The pane id to run it in, such as %1. Optional.")] string? paneId = null)
{
- string target = paneId is null ? string.Empty : $", pane_id=\"{paneId}\"";
+ string target = paneId is null ? string.Empty : $", paneId=\"{paneId}\"";
return $"""
Run this in tmux and report what happened:
tmux_run(command={command.Replace("\"", "\\\"", StringComparison.Ordinal)}{target})
- Read exit_status, timed_out and output from the result.
+ Read exitStatus, timedOut and output from the result.
- - exit_status is the shell's real status, not a guess from the screen.
+ - exitStatus is the shell's real status, not a guess from the screen.
Trust it over anything the output appears to say.
- - If timed_out is true the command is STILL RUNNING in the pane. Do not
- re-run it — that would start a second copy. Either call tmux_run again
- with a longer timeout_seconds, or watch it with tmux_tail_pane.
+ - If timedOut is true the command MAY still be running in the pane.
+ Never re-run it: inspect with tmux_snapshot_pane or continue watching
+ with tmux_tail_pane. A retry could start a second copy.
- If this may take minutes, stop and use tmux_start_job instead, then
collect it with tmux_job.
@@ -53,15 +53,15 @@ public static string DiagnosePane(
$"""
Work out what is wrong in tmux pane {paneId}. Change nothing yet.
- 1. tmux_snapshot_pane(pane_id="{paneId}") — content, cursor, size and the
+ 1. tmux_snapshot_pane(paneId="{paneId}") — content, cursor, size and the
running command in one call.
- 2. Read pane.current_command. A shell means whatever ran has finished; a
+ 2. Read pane.currentCommand. A shell means whatever ran has finished; a
program name means it is still going and may simply be slow.
3. If pane.dead is true the program exited: the screen shows its last
output. tmux_respawn_pane restarts it.
- 4. If content_truncated is set, call again with a larger max_lines — the
+ 4. If content.truncated is true, call again with a larger maxLines — the
interesting line may be above what you were shown.
- 5. If alternate_screen is true, a full-screen program owns the pane and
+ 5. If alternateScreen is true, a full-screen program owns the pane and
scrollback holds what was there BEFORE it started, not its output.
6. To watch it change, call tmux_tail_pane and keep the cursor. Do not
re-capture the whole pane repeatedly.
@@ -83,9 +83,9 @@ public static string BuildWorkspace(
1. tmux_create_session(name="{sessionName}", width=200, height=50).
Give a size: nothing will attach, and a session with no client stays
- at 80x24, which wraps most output. Keep the returned pane_id as A.
- 2. tmux_split_pane(pane_id=A, direction="Below") — keep its pane_id as B.
- 3. tmux_split_pane(pane_id=B, direction="Right") — keep its pane_id as C.
+ at 80x24, which wraps most output. Keep the returned paneId as A.
+ 2. tmux_split_pane(paneId=A, direction="Below") — keep its paneId as B.
+ 3. tmux_split_pane(paneId=B, direction="Right") — keep its paneId as C.
4. Label them so a human can tell them apart:
tmux_set_pane_title on A, B and C.
5. Start what belongs in each with tmux_send_keys. No wait is needed
@@ -106,18 +106,18 @@ public static string InterruptPane(
$"""
Stop whatever is running in tmux pane {paneId} and confirm it stopped.
- 1. tmux_list_panes and note current_command for {paneId}. That is the
+ 1. tmux_list_panes and note currentCommand for {paneId}. That is the
thing you are trying to change; comparing it before and after is the
only reliable check.
- 2. tmux_send_keys(pane_id="{paneId}", keys="C-c", literal=false) —
+ 2. tmux_send_keys(paneId="{paneId}", keys="C-c", literal=false) —
tmux reads C-c as an interrupt only when literal is false.
- 3. Read current_command again. Back to a shell means it worked.
+ 3. Read currentCommand again. Back to a shell means it worked.
Do not wait on a prompt pattern to decide this: a prompt glyph you did
not predict reads as failure, and the terminal echoes ^C whenever the
signal is DELIVERED, whether or not the program died.
- If current_command is unchanged, the program is ignoring the interrupt.
+ If currentCommand is unchanged, the program is ignoring the interrupt.
Stop and ask what to do. Do not escalate to C-\ or kill on your own —
SIGQUIT can dump core, and killing the pane destroys its scrollback.
""";
diff --git a/src/LibTmux.Mcp/README.md b/src/LibTmux.Mcp/README.md
index 2a974b4..afb66b5 100644
--- a/src/LibTmux.Mcp/README.md
+++ b/src/LibTmux.Mcp/README.md
@@ -49,163 +49,19 @@ build and learn whether it passed. Watch a dev server come up. Find which of
eleven panes is showing the stack trace. Lay out a workspace and drive it.
The design goal is that an assistant never gets **stuck** and never **wastes
-context**: no tool polls, no tool returns unbounded output, and no failure
-comes back as "an error occurred".
+context**: waits are event-driven when control mode is available and use a
+bounded polling fallback otherwise; no tool returns unbounded output, and no
+failure comes back as "an error occurred".
-## Waiting, not polling
+## How it behaves
-The tool an assistant reaches for first is usually the wrong one. These four
-cover the cases, and the server's instructions steer between them:
+What to wait on rather than poll, what bounds every result, which tools each
+safety tier registers, and what a resource subscription holds open:
+[how the server behaves](https://github.com/libtmux/libtmux-dotnet/blob/master/docs/mcp/README.md).
-| You want | Use | Why |
-|---|---|---|
-| Run a command, know if it worked | `tmux_run` | Waits, returns the shell's **real exit status** |
-| The same, but it takes minutes | `tmux_start_job` → `tmux_job` | Returns a handle at once; collect later |
-| Output you did **not** start | `tmux_wait_for_text` | Wakes on the pane printing, not on a timer |
-| Watch a pane across turns | `tmux_tail_pane` | Answers only what is **new** since its cursor |
-
-A client that speaks the [Tasks extension](https://modelcontextprotocol.io) can
-start `tmux_run`, `tmux_wait_for_text`, `tmux_wait_for_channel` or `tmux_job`
-as a task and collect the result later — the protocol's own version of what
-`tmux_start_job` does by hand. It is offered, never required, so a client
-without it keeps the blocking call it had. A listing stays a plain call: making
-it a task would cost a round trip to collect an answer that was already there.
-
-Nothing here sleeps in a loop. A wait subscribes to tmux's own
-[control mode](https://github.com/tmux/tmux/wiki/Control-Mode), so tmux reports
-pane output as it happens and the wait is released the moment there is
-something to look at.
-
-Two details make that safe. The control client attaches with `ignore-size`
-(tmux 3.2+), so it never drags the window down to its own size; and it is
-reference counted per session, so it exists only while a wait is running. What
-arrives on that stream is the pane's raw terminal bytes, so it is used as a
-signal and never as content — the text you get always comes from a capture,
-which is what tmux has already rendered.
-
-If control mode cannot start, waits fall back to polling. Cost changes;
-answers do not.
-
-The tools are ordinary classes, so an application that already hosts an
-assistant can run one directly instead of launching a second process:
-
-
-```csharp
-using LibTmux;
-using LibTmux.Mcp;
-
-WriteTools tools = McpTools.Writing(server);
-
-RunResult result = await tools.RunAsync(
- "test -f /etc/hostname && echo present",
- pane.Id.ToString(),
- timeoutSeconds: 20,
- cancellationToken: ct);
-
-// The status comes from the shell, not from reading the screen, so a
-// command that prints nothing still says what it did.
-Console.WriteLine($"exit {result.ExitStatus}, timed out: {result.TimedOut}");
-```
-
-
-## Nothing returns unbounded output
-
-Every content-bearing result is cut to a budget, keeps the **newest** lines,
-and says what it dropped:
-
-```json
-{
- "lines": ["...", "make: *** [build] Error 1"],
- "truncated": true,
- "droppedLines": 407,
- "droppedBytes": 2034
-}
-```
-
-A reader that cannot see lines are missing concludes the pane never printed
-them:
-
-
-```csharp
-using LibTmux;
-using LibTmux.Mcp;
-
-ReadTools reading = McpTools.Reading(server);
-
-CaptureResult captured = await reading.CapturePaneAsync(
- pane.Id.ToString(),
- includeHistory: true,
- maxLines: 5,
- cancellationToken: ct);
-
-// The newest line says what happened, so the budget keeps the end and
-// reports what was dropped — silence would look like nothing printed.
-Console.WriteLine(captured.Content.ToDisplayString());
-Console.WriteLine($"dropped {captured.Content.DroppedLines} earlier lines");
-```
-
-
-`tmux_tail_pane` avoids the problem instead of managing it. Pass its cursor
-back and the tenth read of a busy pane costs what the first did:
-
-
-```csharp
-using LibTmux;
-using LibTmux.Mcp;
-
-ReadTools reading = McpTools.Reading(server);
-string paneId = pane.Id.ToString();
-
-// A first call establishes a position and returns nothing, so watching
-// a pane never starts by paying for a screenful nobody asked for.
-TailResult first = await reading.TailPaneAsync(paneId, cancellationToken: ct);
-
-await reading.WaitForTextAsync(
- paneId,
- patterns: null,
- timeoutSeconds: 5,
- cancellationToken: ct);
-
-TailResult next = await reading.TailPaneAsync(paneId, first.Cursor, cancellationToken: ct);
-Console.WriteLine($"{next.Content.Lines.Count} new lines");
-```
-
-
-To offer these beside your own tools rather than as a separate process:
-
-
-```csharp
-using LibTmux;
-using LibTmux.Mcp;
-using Microsoft.Extensions.DependencyInjection;
-
-ServiceCollection services = new();
-services.AddLogging();
-
-// Registers the tools, resources and prompts, and gates them on the
-// tier. Choose the transport yourself — this returns the builder.
-McpServerComposition.Add(
- services,
- new ServerPolicy { Tier = SafetyTier.ReadOnly },
- server.ConnectionOptions,
- callerPaneId: null);
-```
-
-
-## Three tiers, and a tool you do not have cannot be called
-
-`LIBTMUX_SAFETY` picks how much of tmux is exposed. Tools above the tier are
-**not registered**, so they never reach the model's list:
-
-| `LIBTMUX_SAFETY` | Offers | Example |
-|---|---|---|
-| `readonly` | Reading only | `tmux_capture_pane`, `tmux_search_panes` |
-| `mutating` *(default)* | Reading, creating, changing | `tmux_run`, `tmux_split_pane` |
-| `destructive` | Everything, including removal | `tmux_kill_session` |
-
-A tier bounds the tools, not the intent: an assistant denied `tmux_kill_session`
-can still type `exit` into a pane with `tmux_send_keys`. Use `readonly` when
-that distinction matters.
+The surface itself is generated by asking the server what it advertises, so it
+cannot describe a tool that is not there:
+[the tool reference](https://github.com/libtmux/libtmux-dotnet/blob/master/docs/mcp/tools.md).
## Configuration
@@ -218,38 +74,13 @@ that distinction matters.
| `LIBTMUX_MCP_MAX_LINES` | `500` | Default line budget |
| `LIBTMUX_MCP_MAX_BYTES` | `128000` | Byte budget per result |
+`LIBTMUX_SAFETY` picks how much of tmux is exposed: a tool above the tier is
+not registered, so it never reaches the model's list.
+
An unreadable value is clamped and logged rather than refused — except
`LIBTMUX_SAFETY`, where anything unrecognised falls to `readonly`, because a
typo must never widen what the server offers.
-## Resources and prompts
-
-Six resources expose the hierarchy without a tool call — `tmux://hierarchy`,
-`tmux://sessions`, `tmux://sessions/{id}/panes`, `tmux://panes/{id}/content`,
-`tmux://self`, `tmux://servers`. A client can pin or refresh one on its own
-initiative; one nobody reads costs nothing.
-
-A client that subscribes to `tmux://hierarchy`, `tmux://sessions` or
-`tmux://servers` is told when they change, from tmux's own notifications rather
-than from a timer — so a view goes stale only when something actually moved.
-That watcher holds a second control client, started on the first subscription
-and stopped with the last, attached with `no-output` because it wants the
-hierarchy and not every byte a pane prints.
-
-Both subscription shapes are served: `resources/subscribe`, and the
-`subscriptions/listen` stream that replaced it in the 2026-07-28 revision.
-The newer one is answered by this server rather than by the SDK's built-in
-handling, because that grants the subscription without telling the application
-— which would leave a client subscribed to a watcher nobody started, waiting
-for events that never come.
-
-Long calls report progress while they run, so a wait shows as running rather
-than hung. It costs nothing when the client asks for none.
-
-Four prompts package workflows that are easy to get wrong:
-`tmux_run_and_report`, `tmux_diagnose_pane`, `tmux_build_workspace`,
-`tmux_interrupt_pane`.
-
## Which pane am I in?
When the client that launched this server was itself inside tmux, the server
@@ -268,13 +99,6 @@ session is torn down while the reply is still being written and you get no
bytes back. A real client holds the stream open for the session, which is what
the pause imitates.
-## Standard output belongs to the protocol
-
-Every log line goes to standard error, and the default level is `Warning` so a
-working server is quiet. A message written to the wrong stream does not produce
-a stray log line — it corrupts the protocol and the client disconnects. That is
-worth knowing if you wrap this in something of your own.
-
## Which tmux it drives
Whatever `tmux` resolves to on the path, or the binary `LIBTMUX_TMUX` names.
diff --git a/src/LibTmux.Mcp/Results/BoundedText.cs b/src/LibTmux.Mcp/Results/BoundedText.cs
index f02b750..415e8ac 100644
--- a/src/LibTmux.Mcp/Results/BoundedText.cs
+++ b/src/LibTmux.Mcp/Results/BoundedText.cs
@@ -5,8 +5,8 @@ namespace LibTmux.Mcp;
/// Terminal text cut to fit a budget, with what was cut reported.
/// The text that fits, oldest first.
/// Whether anything was dropped to make it fit.
-/// How many lines were dropped from the start.
-/// How many UTF-8 bytes were dropped from the start.
+/// How many complete lines were dropped from the start.
+/// The exact number of UTF-8 bytes dropped from the start.
///
/// Dropping is always from the oldest end. A terminal's newest line is the one
/// that says what happened, so a budget that discarded it would answer the
@@ -33,41 +33,61 @@ public static BoundedText Fit(IReadOnlyList lines, int? maxLines, int ma
ArgumentNullException.ThrowIfNull(lines);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
- int firstKept = 0;
+ if (lines.Count == 0)
+ {
+ return Empty;
+ }
+
+ int firstEligible = 0;
if (maxLines is int lineBudget)
{
- firstKept = Math.Max(0, lines.Count - Math.Max(lineBudget, 0));
+ firstEligible = Math.Max(0, lines.Count - Math.Max(lineBudget, 0));
}
// A joined capture can hold one logical line far wider than the pane,
- // so the byte budget has to be applied after the line budget rather
- // than assumed to follow from it. The newest line is kept even when it
- // alone overruns: answering with nothing is never the better answer.
- int bytes = 0;
+ // so apply the byte ceiling to the final newline-joined representation.
+ int retainedBytes = 0;
int start = lines.Count;
- for (int index = lines.Count - 1; index >= firstKept; index--)
+ string? clippedFirstLine = null;
+ for (int index = lines.Count - 1; index >= firstEligible; index--)
{
- int lineBytes = Encoding.UTF8.GetByteCount(lines[index]) + 1;
- if (start < lines.Count && bytes + lineBytes > maxBytes)
+ int separatorBytes = start < lines.Count ? 1 : 0;
+ int lineBytes = Encoding.UTF8.GetByteCount(lines[index]);
+ if (lineBytes <= maxBytes - retainedBytes - separatorBytes)
{
- break;
+ retainedBytes = checked(retainedBytes + separatorBytes + lineBytes);
+ start = index;
+ continue;
}
- bytes += lineBytes;
- start = index;
+ int remaining = maxBytes - retainedBytes - separatorBytes;
+ if (remaining > 0)
+ {
+ string suffix = Utf8Suffix(lines[index], remaining);
+ if (suffix.Length > 0)
+ {
+ clippedFirstLine = suffix;
+ retainedBytes = checked(
+ retainedBytes
+ + separatorBytes
+ + Encoding.UTF8.GetByteCount(clippedFirstLine));
+ start = index;
+ }
+ }
+
+ break;
}
- start = Math.Max(firstKept, start);
- if (start <= 0)
+ int droppedLines = start;
+ bool clipped = clippedFirstLine is not null;
+ bool truncated = droppedLines > 0 || clipped;
+ if (!truncated)
{
return new BoundedText(lines, false, 0, 0);
}
- int droppedBytes = 0;
- for (int index = 0; index < start; index++)
- {
- droppedBytes += Encoding.UTF8.GetByteCount(lines[index]) + 1;
- }
+ int totalBytes = JoinedUtf8ByteCount(lines);
+ int droppedBytes = checked(totalBytes - retainedBytes);
string[] kept = new string[lines.Count - start];
for (int index = 0; index < kept.Length; index++)
@@ -75,7 +95,12 @@ public static BoundedText Fit(IReadOnlyList lines, int? maxLines, int ma
kept[index] = lines[start + index];
}
- return new BoundedText(kept, true, start, droppedBytes);
+ if (clipped)
+ {
+ kept[0] = clippedFirstLine!;
+ }
+
+ return new BoundedText(kept, true, droppedLines, droppedBytes);
}
/// Renders the text as one block, noting any loss at the top.
@@ -88,7 +113,50 @@ public string ToDisplayString()
{
string body = string.Join('\n', Lines);
return Truncated
- ? $"[{DroppedLines} earlier lines ({DroppedBytes} bytes) omitted to fit the budget]\n{body}"
+ ? $"[{DroppedLines} complete earlier lines and {DroppedBytes} UTF-8 bytes "
+ + $"omitted from the start to fit the budget]\n{body}"
: body;
}
+
+ private static int JoinedUtf8ByteCount(IReadOnlyList lines)
+ {
+ int bytes = 0;
+ for (int index = 0; index < lines.Count; index++)
+ {
+ bytes = checked(bytes + Encoding.UTF8.GetByteCount(lines[index]));
+ if (index > 0)
+ {
+ bytes = checked(bytes + 1);
+ }
+ }
+
+ return bytes;
+ }
+
+ private static string Utf8Suffix(string line, int byteBudget)
+ {
+ int start = line.Length;
+ int bytes = 0;
+ while (start > 0)
+ {
+ int previous = start - 1;
+ if (previous > 0
+ && char.IsLowSurrogate(line[previous])
+ && char.IsHighSurrogate(line[previous - 1]))
+ {
+ previous--;
+ }
+
+ int runeBytes = Encoding.UTF8.GetByteCount(line.AsSpan(previous, start - previous));
+ if (runeBytes > byteBudget - bytes)
+ {
+ break;
+ }
+
+ bytes += runeBytes;
+ start = previous;
+ }
+
+ return line[start..];
+ }
}
diff --git a/src/LibTmux.Mcp/Results/JobResults.cs b/src/LibTmux.Mcp/Results/JobResults.cs
index 6e844bf..280d3ae 100644
--- a/src/LibTmux.Mcp/Results/JobResults.cs
+++ b/src/LibTmux.Mcp/Results/JobResults.cs
@@ -19,7 +19,11 @@ public enum JobState
/// A command that outlives the call that started it.
/// The handle to ask about it with.
/// The pane it runs in.
-/// What was run.
+/// The named socket it runs on, or null for a socket path.
+/// The socket path it runs on, or null for a named socket.
+/// The exact socket endpoint, as a stable opaque digest.
+/// The tmux daemon that owned the pane when it started.
+/// The UTF-8 size of the command, whose text is not retained.
/// Where it has got to.
/// What it exited with, once it has.
/// When it was started.
@@ -29,17 +33,32 @@ public enum JobState
/// Starting one costs a single call and returns at once, so a command that
/// takes ten minutes does not spend ten minutes of the model's turn. The pane
/// keeps running it either way; the job is what makes the result collectable.
+/// Command text is deliberately absent because shell commands commonly contain
+/// credentials; the handle and endpoint are enough to collect or cancel it.
///
public sealed record JobInfo(
string JobId,
string PaneId,
- string Command,
+ string? SocketName,
+ string? SocketPath,
+ string EndpointFingerprint,
+ ServerGeneration ServerGeneration,
+ int CommandBytes,
JobState State,
int? ExitStatus,
DateTimeOffset StartedAt,
DateTimeOffset? EndedAt,
double ElapsedSeconds);
+/// A bounded inventory of background jobs.
+/// The jobs that fit, newest first.
+/// How many jobs the server remembers.
+/// Whether older jobs were omitted to fit the response budget.
+public sealed record JobList(
+ IReadOnlyList Jobs,
+ int TotalJobs,
+ bool Truncated);
+
/// A background command, and whatever it has printed since last asked.
/// Where the command has got to.
///
diff --git a/src/LibTmux.Mcp/Results/Observations.cs b/src/LibTmux.Mcp/Results/Observations.cs
index 26af46c..31ee651 100644
--- a/src/LibTmux.Mcp/Results/Observations.cs
+++ b/src/LibTmux.Mcp/Results/Observations.cs
@@ -97,10 +97,16 @@ public sealed record WaitResult(
///
/// The shell's exit status, or null when the command did not finish in time.
///
-/// Whether the wait ran out before the command finished.
+///
+/// Whether waiting stopped before completion. The shell command may still be
+/// running; inspect the pane and do not retry it. Use tmux_start_job when
+/// work must remain recoverable after the wait.
+///
/// What the command printed, within the budget.
/// How long it took.
/// The timeout actually used, after the server's ceiling.
+/// Whether scrollback dropped output before it could be read.
+/// Whether the pre-command output position could no longer be found.
///
/// The command runs in a subshell, so a cd or an export in it
/// does not survive into the next call.
@@ -111,7 +117,9 @@ public sealed record RunResult(
bool TimedOut,
BoundedText Output,
double ElapsedSeconds,
- double EffectiveTimeoutSeconds);
+ double EffectiveTimeoutSeconds,
+ bool LinesMissed = false,
+ bool AnchorLost = false);
/// One pane whose text matched a search.
/// The pane that matched.
diff --git a/src/LibTmux.Mcp/Results/SearchResultBudget.cs b/src/LibTmux.Mcp/Results/SearchResultBudget.cs
new file mode 100644
index 0000000..e95677e
--- /dev/null
+++ b/src/LibTmux.Mcp/Results/SearchResultBudget.cs
@@ -0,0 +1,164 @@
+using ModelContextProtocol;
+
+namespace LibTmux.Mcp;
+
+/// Builds one search result within global line and byte ceilings.
+internal sealed class SearchResultBudget
+{
+ private readonly string _pattern;
+ private readonly int _maximumPanes;
+ private readonly int _maxMatches;
+ private readonly int _maxBytes;
+ private readonly List _panes = [];
+ private int _bytes;
+ private int _matches;
+
+ /// Initializes a result budget, reserving its fixed metadata first.
+ internal SearchResultBudget(
+ string pattern,
+ int maximumPanes,
+ int maxMatches,
+ int maxBytes)
+ {
+ ArgumentNullException.ThrowIfNull(pattern);
+ ArgumentOutOfRangeException.ThrowIfNegative(maximumPanes);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxMatches);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
+ _pattern = pattern;
+ _maximumPanes = maximumPanes;
+ _maxMatches = maxMatches;
+ _maxBytes = maxBytes;
+
+ _bytes = Utf8JsonBudget.GetStructuredToolResultByteCount(
+ new SearchResult(
+ _pattern,
+ _maximumPanes,
+ [],
+ Truncated: false),
+ ToolJson.Options);
+ if (_bytes > _maxBytes)
+ {
+ throw new McpException(
+ $"The search pattern alone exceeds this server's {maxBytes} UTF-8 byte "
+ + $"limit. Use a shorter pattern or raise {ServerPolicy.MaxBytesVariable}.");
+ }
+ }
+
+ /// Adds a matching line or explains why it cannot be added.
+ internal SearchMatchBudgetOutcome TryAdd(
+ string paneId,
+ string windowId,
+ string sessionId,
+ List paneMatches,
+ MatchedLine match)
+ {
+ ArgumentNullException.ThrowIfNull(paneId);
+ ArgumentNullException.ThrowIfNull(windowId);
+ ArgumentNullException.ThrowIfNull(sessionId);
+ ArgumentNullException.ThrowIfNull(paneMatches);
+ ArgumentNullException.ThrowIfNull(match);
+ if (_matches >= _maxMatches)
+ {
+ return SearchMatchBudgetOutcome.GlobalLimit;
+ }
+
+ int remainingBytes = _maxBytes - _bytes;
+ int minimumCurrentBytes = AddedBytes(
+ paneId,
+ windowId,
+ sessionId,
+ paneMatches,
+ new MatchedLine(0, string.Empty));
+ if (minimumCurrentBytes > remainingBytes)
+ {
+ bool anotherPaneNeedsComma = _panes.Count > 0 || paneMatches.Count > 0;
+ int minimumFuturePaneBytes = checked(
+ Utf8JsonBudget.GetStructuredJsonFragmentByteCount(
+ new PaneMatch("%0", "@0", "$0", []),
+ ToolJson.Options)
+ + Utf8JsonBudget.GetStructuredJsonFragmentByteCount(
+ new MatchedLine(0, string.Empty),
+ ToolJson.Options)
+ + (anotherPaneNeedsComma ? 2 : 0));
+ return minimumFuturePaneBytes > remainingBytes
+ ? SearchMatchBudgetOutcome.GlobalLimit
+ : SearchMatchBudgetOutcome.PaneCannotFit;
+ }
+
+ if (match.Text.Length > remainingBytes / 2
+ || System.Text.Encoding.UTF8.GetByteCount(match.Text) > remainingBytes / 2
+ || Utf8JsonBudget.GetStructuredJsonStringContentByteCount(
+ match.Text,
+ ToolJson.Options) > remainingBytes)
+ {
+ return SearchMatchBudgetOutcome.ItemTooLarge;
+ }
+
+ int addedBytes = AddedBytes(paneId, windowId, sessionId, paneMatches, match);
+ if (addedBytes > remainingBytes)
+ {
+ return SearchMatchBudgetOutcome.ItemTooLarge;
+ }
+
+ _bytes += addedBytes;
+ paneMatches.Add(match);
+ _matches++;
+ return SearchMatchBudgetOutcome.Added;
+ }
+
+ /// Commits the matches accumulated for one pane.
+ internal void Commit(
+ string paneId,
+ string windowId,
+ string sessionId,
+ IReadOnlyList paneMatches)
+ {
+ ArgumentNullException.ThrowIfNull(paneMatches);
+ if (paneMatches.Count > 0)
+ {
+ _panes.Add(new PaneMatch(
+ paneId,
+ windowId,
+ sessionId,
+ paneMatches.ToArray()));
+ }
+ }
+
+ /// Builds the bounded result.
+ internal SearchResult Build(int panesSearched, bool truncated) =>
+ new(_pattern, panesSearched, _panes, truncated);
+
+ private int AddedBytes(
+ string paneId,
+ string windowId,
+ string sessionId,
+ List paneMatches,
+ MatchedLine match) =>
+ paneMatches.Count == 0
+ ? checked(
+ Utf8JsonBudget.GetStructuredJsonFragmentByteCount(
+ new PaneMatch(paneId, windowId, sessionId, []),
+ ToolJson.Options)
+ + Utf8JsonBudget.GetStructuredJsonFragmentByteCount(match, ToolJson.Options)
+ + (_panes.Count > 0 ? 2 : 0))
+ : checked(
+ Utf8JsonBudget.GetStructuredJsonFragmentByteCount(match, ToolJson.Options)
+ + 2);
+
+}
+
+/// Why a search match was accepted or refused.
+internal enum SearchMatchBudgetOutcome
+{
+ /// The match was added.
+ Added = 0,
+
+ /// This match is too large, but a smaller one can still fit.
+ ItemTooLarge = 1,
+
+ /// This pane's metadata cannot fit, but a smaller endpoint may.
+ PaneCannotFit = 2,
+
+ /// No further match can fit the global line or byte budget.
+ GlobalLimit = 3,
+}
diff --git a/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs b/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs
new file mode 100644
index 0000000..9e1210d
--- /dev/null
+++ b/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs
@@ -0,0 +1,263 @@
+using System.Text;
+using ModelContextProtocol;
+
+namespace LibTmux.Mcp;
+
+/// Fits terminal text inside a complete structured MCP result.
+internal static class StructuredTextResultBudget
+{
+ private const int MaximumCorrectionSteps = 5;
+ private const int CorrectionSlackBytes = 64;
+
+ /// Keeps the newest text whose complete structured result fits.
+ internal static T Fit(
+ IReadOnlyList lines,
+ int? maxLines,
+ int maxBytes,
+ Func createResult,
+ string resultName)
+ {
+ ArgumentNullException.ThrowIfNull(lines);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes);
+ ArgumentNullException.ThrowIfNull(createResult);
+ ArgumentException.ThrowIfNullOrWhiteSpace(resultName);
+
+ BoundedText fullText = BoundedText.Fit(lines, maxLines, maxBytes);
+ T full = createResult(fullText);
+ int fullBytes = Size(fullText, createResult);
+ if (fullBytes <= maxBytes)
+ {
+ return full;
+ }
+
+ BoundedText emptyText = DropAll(fullText);
+ T minimum = createResult(emptyText);
+ int minimumBytes = Size(emptyText, createResult);
+ if (minimumBytes > maxBytes)
+ {
+ throw new McpException(
+ $"The {resultName} metadata cannot fit this server's {maxBytes} UTF-8 "
+ + $"byte limit. Shorten the request or target metadata, or raise "
+ + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server.");
+ }
+
+ int retainedBytes = JoinedUtf8ByteCount(fullText.Lines);
+ if (retainedBytes == 0)
+ {
+ return minimum;
+ }
+
+ int candidateBytes = EstimateBudget(
+ retainedBytes,
+ maxBytes - minimumBytes,
+ fullBytes - minimumBytes,
+ retainedBytes);
+ candidateBytes = Math.Min(candidateBytes, retainedBytes - 1);
+ T best = minimum;
+ int bestBytes = minimumBytes;
+ int bestBudget = 0;
+ bool foundContent = false;
+ for (int step = 0; step < MaximumCorrectionSteps && candidateBytes > 0; step++)
+ {
+ BoundedText candidateText = Merge(
+ fullText,
+ BoundedText.Fit(fullText.Lines, null, candidateBytes));
+ T candidate = createResult(candidateText);
+ int candidateSize = Size(candidateText, createResult);
+ if (candidateSize <= maxBytes)
+ {
+ best = candidate;
+ bestBytes = candidateSize;
+ bestBudget = candidateBytes;
+ foundContent |= candidateText.Lines.Count > 0;
+ int expanded = EstimateBudget(
+ candidateBytes,
+ maxBytes - minimumBytes,
+ candidateSize - minimumBytes,
+ retainedBytes - 1);
+ expanded = Math.Min(expanded, retainedBytes - 1);
+ if (expanded <= candidateBytes)
+ {
+ break;
+ }
+
+ candidateBytes = expanded;
+ continue;
+ }
+
+ if (candidateBytes == 1)
+ {
+ break;
+ }
+
+ int corrected = EstimateBudget(
+ candidateBytes,
+ maxBytes - minimumBytes,
+ candidateSize - minimumBytes,
+ candidateBytes - 1);
+ candidateBytes = Math.Min(
+ candidateBytes - 1,
+ Math.Max(1, corrected - CorrectionSlackBytes));
+ }
+
+ if (!foundContent)
+ {
+ int high = retainedBytes - 1;
+ int lastFit = 0;
+ int probe = 1;
+ while (probe <= high)
+ {
+ BoundedText probeText = Merge(
+ fullText,
+ BoundedText.Fit(fullText.Lines, null, probe));
+ T probeResult = createResult(probeText);
+ int probeSize = Size(probeText, createResult);
+ if (probeSize > maxBytes)
+ {
+ high = probe - 1;
+ break;
+ }
+
+ best = probeResult;
+ bestBytes = probeSize;
+ bestBudget = probe;
+ lastFit = probe;
+ if (probe == high)
+ {
+ break;
+ }
+
+ probe = probe > high / 2 ? high : probe * 2;
+ }
+
+ int low = lastFit + 1;
+ while (low <= high)
+ {
+ int midpoint = low + ((high - low) / 2);
+ BoundedText midpointText = Merge(
+ fullText,
+ BoundedText.Fit(fullText.Lines, null, midpoint));
+ T midpointResult = createResult(midpointText);
+ int midpointSize = Size(midpointText, createResult);
+ if (midpointSize <= maxBytes)
+ {
+ best = midpointResult;
+ bestBytes = midpointSize;
+ bestBudget = midpoint;
+ low = midpoint + 1;
+ }
+ else
+ {
+ high = midpoint - 1;
+ }
+ }
+ }
+
+ int headroomThreshold = Math.Max(256, maxBytes / 100);
+ if (maxBytes - bestBytes >= headroomThreshold
+ && bestBudget < retainedBytes - 1)
+ {
+ int low = bestBudget + 1;
+ int high = retainedBytes - 1;
+ while (low <= high)
+ {
+ int midpoint = low + ((high - low) / 2);
+ BoundedText midpointText = Merge(
+ fullText,
+ BoundedText.Fit(fullText.Lines, null, midpoint));
+ T midpointResult = createResult(midpointText);
+ int midpointSize = Size(midpointText, createResult);
+ if (midpointSize <= maxBytes)
+ {
+ best = midpointResult;
+ low = midpoint + 1;
+ }
+ else
+ {
+ high = midpoint - 1;
+ }
+ }
+ }
+
+ return best;
+ }
+
+ private static int Size(BoundedText text, Func createResult)
+ {
+ var skeletonText = new BoundedText(
+ [],
+ text.Truncated,
+ text.DroppedLines,
+ text.DroppedBytes);
+ T skeleton = createResult(skeletonText);
+ int skeletonSize = Utf8JsonBudget.GetStructuredToolResultByteCount(
+ skeleton,
+ ToolJson.Options);
+ int textSize = Utf8JsonBudget.GetStructuredJsonFragmentByteCount(
+ text,
+ ToolJson.Options);
+ int skeletonTextSize = Utf8JsonBudget.GetStructuredJsonFragmentByteCount(
+ skeletonText,
+ ToolJson.Options);
+ return checked(skeletonSize + textSize - skeletonTextSize);
+ }
+
+ private static BoundedText DropAll(BoundedText text)
+ {
+ if (text.Lines.Count == 0)
+ {
+ return text;
+ }
+
+ return Merge(text, BoundedText.Fit(text.Lines, 0, 1));
+ }
+
+ private static BoundedText Merge(BoundedText earlier, BoundedText later)
+ {
+ if (!later.Truncated)
+ {
+ return earlier;
+ }
+
+ return new BoundedText(
+ later.Lines,
+ Truncated: true,
+ DroppedLines: checked(earlier.DroppedLines + later.DroppedLines),
+ DroppedBytes: checked(earlier.DroppedBytes + later.DroppedBytes));
+ }
+
+ private static int EstimateBudget(
+ int referenceBytes,
+ int availableBytes,
+ int variableBytes,
+ int maximumBytes)
+ {
+ if (maximumBytes <= 0)
+ {
+ return 0;
+ }
+
+ if (availableBytes <= 0 || variableBytes <= 0)
+ {
+ return 1;
+ }
+
+ long estimate = (long)referenceBytes * availableBytes / variableBytes;
+ return (int)Math.Clamp(estimate, 1, maximumBytes);
+ }
+
+ private static int JoinedUtf8ByteCount(IReadOnlyList lines)
+ {
+ int bytes = 0;
+ for (int index = 0; index < lines.Count; index++)
+ {
+ bytes = checked(bytes + Encoding.UTF8.GetByteCount(lines[index]));
+ if (index > 0)
+ {
+ bytes = checked(bytes + 1);
+ }
+ }
+
+ return bytes;
+ }
+}
diff --git a/src/LibTmux.Mcp/ServerInstructions.cs b/src/LibTmux.Mcp/ServerInstructions.cs
index 226489b..91fa0f9 100644
--- a/src/LibTmux.Mcp/ServerInstructions.cs
+++ b/src/LibTmux.Mcp/ServerInstructions.cs
@@ -83,7 +83,8 @@ public static string Compose(ServerPolicy policy, string? callerPaneId)
"Drives tmux: terminal sessions, windows and panes on this machine. "
+ "Hierarchy is Server > Session > Window > Pane. Target by id — %1 is a pane, "
+ "@1 a window, $1 a session — because ids survive renames and layout changes. "
- + "Every tool takes socket_name; tmux_list_servers finds the sockets.";
+ + "Tools that address tmux take socketName; tmux_list_servers discovers sockets, "
+ + "and tmux_list_jobs spans the jobs recorded by this MCP process.";
private const string Scope =
"USE FOR: tmux panes, windows, sessions, splits, scrollback, copy mode, "
@@ -105,9 +106,9 @@ public static string Compose(ServerPolicy policy, string? callerPaneId)
+ "tmux_tail_pane, passing back its cursor.";
private const string Budget =
- "COST: capture tools keep the NEWEST lines and report what they dropped; "
- + "if content_truncated is set, lines are missing, not absent. Prefer "
- + "tmux_tail_pane over re-capturing a pane you are watching.";
+ "COST: terminal text keeps the NEWEST lines and reports what was dropped. "
+ + "Check content.truncated, output.truncated, or tail.truncated; true means "
+ + "lines are missing, not absent. Prefer tmux_tail_pane while watching.";
private const string Gaps =
"ABSENT ON PURPOSE: no hook writing (a hook outlives this conversation — put "
diff --git a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs
new file mode 100644
index 0000000..267dcfe
--- /dev/null
+++ b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs
@@ -0,0 +1,915 @@
+using System.Runtime.Versioning;
+using Microsoft.Extensions.Logging;
+
+namespace LibTmux.Mcp;
+
+/// Owns subscriptions and one control run for an exact tmux generation.
+[UnsupportedOSPlatform("windows")]
+internal sealed class HierarchyEndpointWatch : IAsyncDisposable
+{
+ private static readonly TimeSpan InitialRecoveryDelay = TimeSpan.FromMilliseconds(100);
+ private static readonly TimeSpan MaximumRecoveryDelay = TimeSpan.FromSeconds(2);
+ private readonly object _gate = new();
+ private readonly ILogger? _logger;
+ private readonly Func _delay;
+ private readonly Func? _beforeRecoveryOutcome;
+ private readonly Action? _recoveryOutcomeObserved;
+ private readonly CancellationTokenSource _lifetime = new();
+ private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
+ private readonly Dictionary