Skip to content

psmux, improved MCP support - #12

Merged
tony merged 14 commits into
masterfrom
psmux
Aug 22, 2026
Merged

psmux, improved MCP support#12
tony merged 14 commits into
masterfrom
psmux

Conversation

@tony

@tony tony commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add an experimental query-only psmux preview for native Windows .NET and WSL-to-Windows interop. PsmuxServer, PsmuxSession, PsmuxWindow, and PsmuxPane read one isolated session, its windows and panes, and pane text. It is a separate analyzer-clean surface, so preview callers never suppress CA1416 on types whose tmux contracts psmux cannot preserve.
  • Add fail-closed trust for that preview: an exact executable SHA-256, the audited upstream commit and clean version banner, an absolute local-drive PSMUX_DATA_DIR, and an explicit high-entropy namespace holding exactly one session. Lifecycle, mutation, chaining, control mode, raw commands, socket paths, and MCP are rejected rather than approximated.
  • Add TmuxEventsDroppedEvent so a bounded control-stream reader learns it fell behind, with per-report and cumulative counts, instead of silently missing notifications.
  • Change MCP result bounding: every serialized tool and resource result obeys a hard UTF-8 byte ceiling, search and terminal text obey global line and byte ceilings, and tail cursors are authenticated, bounded in size, and tied to the exact endpoint, server generation, and pane.
  • Change MCP Tasks to a bounded store — a capped set of active executions and a bounded retained result set — applied to waits and job collection only. tmux_run is synchronous again; work that must survive a client disconnect uses tmux_start_job and tmux_job.
  • Fix lifetime and routing defects in streaming and jobs. Hierarchy and pane-activity streams keep independent subscribers and recover after a stream ends, jobs retain their originating endpoint and generation and drain watchers during disposal, and a failed or cancelled control-client attach cleans up without replacing its primary error.
  • Add release gating that proves the preview before publishing: the tag build now depends on the full dotnet gate, the supported tmux matrix, published-artifact metadata checks, and a self-hosted native Windows plus WSL smoke on net8.0 and net10.0.

Publishing the exact accepted psmux artifact, or reviewing a published replacement and pinning its hash, remains a prerequisite to shipping the preview. docs/psmux.md states that boundary.

Changes by area

Core library

  • src/LibTmux/Psmux/: the public preview types with their connection and capture options.
  • src/LibTmux/Internal/Psmux*: binary trust, client-side command allowlist, target grammar, session routing, process environment, and compatibility rules for the pinned build.
  • src/LibTmux/Internal/Tmux*: endpoint resolution, entity lookup, generation guarding, mutation sequencing, and version-banner parsing split out of TmuxConnection.
  • src/LibTmux/ControlMode/ControlModeEventBuffer.cs: bounded notification buffer that reports drops rather than letting a slow consumer stall commands.
  • src/LibTmux/Testing/TemporaryScopeCleanup.cs: scope disposal that preserves the primary failure when cleanup also fails.

MCP server

  • Filters/: Utf8JsonBudget plus tool and resource response-budget filters, and tool annotation lookup.
  • Results/: search and structured-text budgets that fit a complete result rather than only its payload.
  • Streaming/: HierarchyEndpointWatch owns subscriptions and one control run per generation, SubscriptionAdmission caps concurrent listen streams, and TailCursor becomes authenticated and endpoint-bound.
  • Tasks/BoundedMcpTaskStore.cs: refuses at task creation, which bounds queued executions instead of only remembered results.
  • Jobs/JobStore.cs: endpoint- and generation-bound handles, serialized collection, and capacity retained until a cancelled job's watcher ends.

Release and CI

  • .github/workflows/release.yml: split into validate, dotnet gate, tmux compatibility, psmux artifact metadata, psmux native/WSL, and publish. Publish now consumes the packages the gate produced.
  • .github/workflows/dotnet.yml and dotnet-tmux.yml: callable from the release workflow; the gate also checks the rendered API reference.
  • eng/psmux/Invoke-PsmuxSmoke.ps1: the harness a maintainer and the release runner both use. It owns a fresh data directory, refuses an existing one, and removes only the exact session identity it created.

Docs and tooling

  • docs/psmux.md: the trust boundary, exact compatibility limits, and the reproduction steps.
  • eng/docs/render_api_reference.py: renders the reference from the public surface using each partial type's canonical summary, checked in CI alongside the other catalogs.

Design decisions

  • A separate preview surface, not annotations relaxed on the tmux one: Server, Session, Window, and Pane keep their Windows-unsupported annotations because their lifecycle, mutation, grouping, control-mode, and atomic stale-handle contracts still require real tmux. Distinct Psmux* types give a caller a compile error where a suppression would have given them a silent behavioral gap.
  • Fail closed on the artifact, not on the banner: LibTmux hashes the client and checks its audited build markers before every launch, then requires the exact two-line banner. A matching source banner is not a substitute for the pinned hash — the upstream release build runs a startup reaper that can terminate listeners owned by another data directory, and that reaper runs before -V is parsed, so rejecting its banner would already be too late.
  • tmux_run stays synchronous: an SDK task has no durable tmux job handle if its client disconnects. tmux_start_job returns one, so it is the recoverable path and tmux_run stops pretending to be.
  • Budgets bound the complete result: trimming a payload still failed when metadata pushed the serialized result over the ceiling. Fitting the whole result, with a reserve for the protocol metadata appended after filters run, is what makes the ceiling real. Job collection advances only after its complete response fits, so retrying a rejected call does not lose output.
  • Release gates own the psmux proof: the native and WSL legs need a self-hosted Windows runner and the accepted artifact. Requiring them per commit would block ordinary work on hardware that is not in the merge path; requiring them at the tag is where it counts, because a version on nuget.org can never be deleted.

Test plan

  • dotnet build LibTmux.slnx --configuration Release --warnaserror — clean, no warnings
  • Unit tests on net10.0 — the new connection, control-mode, budget, job store, streaming, and psmux facade suites pass
  • Every document validator the gate runs: public API, rendered API reference, capabilities, workflows, snippets, MCP tool catalog
  • pytest eng with LIBTMUX_PYTHON_REPOSITORY pointed at a checkout holding the pinned Python libtmux revision — the ledger tests exit early without it
  • Integration suite and the supported tmux matrix (CI)
  • macOS arm64 advisory lane (CI)
  • Native Windows and WSL psmux smoke — requires the self-hosted runner and the accepted artifact, so it runs at the release tag

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

tony added 10 commits August 22, 2026 09:37
why: Provide a fail-closed Windows query path without implying tmux
parity.

what:
- Add exact-binary psmux routing and read-only public query types
- Harden transport, control-mode, MCP streaming, jobs, and budgets
- Add native and WSL release gates, docs, examples, and tests
why: The keyless unsubscribe dropped one arbitrary reference and
returned, so a resource subscribed against a second endpoint or a
second holder kept a callback and a control client alive that no
caller could reach afterwards.

what:
- Remove every subscriber's reference to the URI within an endpoint
- Visit every endpoint rather than returning after the first match
- Cover both leaks, bounding the waits so a regression names itself
why: A socket chosen by a name factory or by LIBTMUX_SOCKET_NAME leaves
the options empty, so a job reported no socket and rejected the very
socketName its own pane was reached through.

what:
- Carry the resolved socket path alongside the resolved name
- Expose the resolved pair and record it when a job starts
- Cover a factory socket, the default name, and a relative path
why: Hashing the rows below the cursor as one block replayed every one
of them when a single row was redrawn, so tmux_wait_for_text could
match text that was already on screen before the wait began.

what:
- Record a truncated digest per row for the tracked window
- Skip each unchanged row and report only what was rewritten
- Keep the whole-suffix hash as the skip-everything fast path
- Assert the worst-case token still decodes within its ceilings
why: dotnet format rejects a property whose type wraps onto its own
line, and the shape it rewrites to reads worse than the delegate the
signature was always describing.

what:
- Declare a CommandHandler delegate for the fake endpoint's override
- Note why a cursor that moved up reports the rows above its anchor
why: Resolution already guarantees a socket name or a socket path and
never both, so choosing between them again where a job records them
restates an invariant that lives elsewhere.

what:
- Assign both recorded fields straight from the resolved pair
why: A cursor sitting on the last row records no rows below it, which
is the shape almost every tail returns and the one whose null fields
a serializer change would silently stop round-tripping.

what:
- Round-trip a cursor with nothing below it
why: An MCP result carries its cursor twice, so hex digests cost two
bytes of every response per byte recorded; at the smallest configurable
ceiling a tail answered with a cursor and no lines at all.

what:
- Encode the tracked window as base64url rather than hex
- Decode it once per read instead of once per compared row
- Measure the budget against a real widest cursor, not a placeholder
why: kill-server is answered when the command lands rather than when
the server exits, so the replacement could be created on the dying
server and die with it, leaving the socket with no server and the
connect retries reporting only that discovery failed.

what:
- Wait for the recorded server process to go before starting a new one
- Fail on the replacement's own exit status instead of five seconds later
- Probe with list-sessions, since a socket file outlives its server
why: Trunk corrected these comments to v7.0.1 while this branch was in
flight, and the two lines the branch adds to release.yml were outside
that commit's reach. The rebase left a v7.0.1 SHA labelled v4.3.0, and
the version comment is the only readable half of a SHA pin.

what:
- Label both added actions/checkout pins v7.0.1
tony added 4 commits August 22, 2026 10:34
why: alpha.7 shipped tmux_run in TaskCapableTools.Waiting, so a client
that adopted the task handle experienced the old behaviour. The change
read as one clause inside a bullet about Task bounding, which is not
where a reader looks for an incompatibility.

what:
- Split the Task bounding entry from the tmux_run change
- State the break with the previous/new/reason/action frame
why: nuget.org renders the package README, and it carried 330 lines of
behaviour a reader does not need before installing. docs/mcp/ held only
the generated tool reference, so there was nowhere else for it.

what:
- Move waiting, result bounds, tiers, subscriptions and the stdout rule
  to docs/mcp/README.md
- Leave the package README as install, configuration and compatibility
- Register the new document in ReadmeExampleTests and sync_snippets, so
  its four blocks stay compiled and anchored
why: TmuxEventsDroppedEvent is new public API a caller has to branch on,
and control-mode.md described the marker without showing what handling
it looks like.

what:
- Add NoticeDroppedEvents, which treats the marker as cache
  invalidation rather than as a log line
- Anchor it under the buffer paragraph in docs/modes/control-mode.md
why: The LibTmux README named the psmux source commit and called a second
one unsafe. Both are bare SHAs, which WRITING.md bans outside evidentiary
artefacts, and the first duplicated PsmuxServer.SupportedCommit, so the
prose went stale the moment that constant moved. Somebody deciding
whether to install does not read upstream history.

what:
- State tmux, framework and platform support in a Compatibility table,
  after the API story rather than between it and the golden path
- Point at docs/psmux.md for the build the preview accepts
- Move the release variables and runner labels to CONTRIBUTING, which
  owns releases and where verify_workflows already checks the names
- Cut release-gate wording from the root README and the docs index
@tony tony changed the title Add a query-only psmux preview and harden the MCP and core surfaces psmux, improved MCP support Aug 22, 2026
@tony
tony merged commit b959647 into master Aug 22, 2026
21 checks passed
tony added a commit that referenced this pull request Aug 22, 2026
why: Windows and WSL callers had no supported way to read a tmux-like
session, and the MCP server could answer with a result no client could
hold, lose subscribers when a stream ended, or route a job to an
endpoint it did not start on.

what:
- Add PsmuxServer, PsmuxSession, PsmuxWindow and PsmuxPane: a
  query-only preview that pins one audited psmux build and rejects
  lifecycle, mutation, chaining, control mode and raw commands
- Add TmuxEventsDroppedEvent so a bounded control reader learns it
  fell behind instead of missing notifications silently
- Bound every serialized MCP tool and resource result, and tie tail
  cursors to the endpoint, server generation and pane that issued them
- Bound the MCP task store, and stop offering tmux_run as a task
- Keep hierarchy and pane-activity subscribers independent across a
  stream ending, and bind a job to its originating endpoint
@tony
tony deleted the psmux branch August 22, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants