Skip to content

Draw the MCP worked example from tmux's own state - #249

Merged
tony merged 18 commits into
mainfrom
feat/mcp-tmux-state-20261103
Nov 5, 2026
Merged

tony merged 18 commits into
mainfrom
feat/mcp-tmux-state-20261103

Conversation

@tony

@tony tony commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

The worked example on each port's MCP page drew only what the calls returned. On the Go page, after split_window (step 3) the preview said "Panes, sizes not read yet: %0 sh, %1", and the split was drawn only at the next list_panes, whose caption read "list_panes read the panes; tmux itself is unchanged." Go's split_window answers {"paneId":"%1"}, with no layout and no size, so the preview deleted the old layout and had nothing to draw. Every port has the same shape.

This records what tmux looked like after every call and draws that. It is on main after #247 and includes the Julia recording from #248, so it supersedes #248.

Recorder

gen-mcp-transcript.mjs reads the recorder's own sandbox tmux before the first call and after each one, and stores it as tmuxBefore and as tmux on the step. readTmuxState takes the sessions, the windows with the window_layout tmux reports, and the panes with command, rectangle and active flag, in one tmux invocation on the sandbox's socket. It refuses a socket outside the sandbox and a sandbox with two servers, reads a missing server or a server with no session as an empty tmux, and fails on any other read error, so a step whose state could not be read is never written.

The state is read after the pause that stands for an agent reading the result, and again until two reads agree and every pane runs the expected shell. A pane is named tmux until its shell replaces it and a window is renamed for the program a pane runs, so a single read straight after a call is not stable. A window tmux still names itself must also carry the name of its active pane's program. A state that never settles is the existing unsettled-pane error, which records again (see Review follow-ups).

recordingStateProblems holds the checks the recorder and the page share. Each part refers to the others (window to session, pane to window), one window and one pane are active, a window's layout names exactly its panes at the rectangles they report, a call names only ids the state before it had, a result returns only ids the state after it has, and a tool that only reads leaves tmux as it was. The id checks are what catch a state read a step late or from another server. The recorder runs them, --check requires them of the stored file, and recordingHolds applies them on the page.

The state goes through the same sanitize as every other part of a step, and the finished transcript through hostLeaks (see Review follow-ups). Writing this found one real miss: Swift writes its project path with escaped slashes inside JSON text, so the committed Swift recording named a random sandbox directory (/tmp/t239-swift-…) that neither the rewrite nor the scan saw, and it changed on every run. Both handle the escaped form.

Preview

replayFrames draws frame i from the state recorded after call i and frame 0 from the state before the first call. The caption reads "tmux after <tool>", and "unchanged, the call only reads" after a tool that only reads. The provenance line says the layout and panes come from the recording's own tmux server after each call and a pane's output from the results of the calls. Each call's Result panel is unchanged, and a pane still shows what the calls typed into it and read from it. A recording that stores no state keeps drawing from its results; the tests keep that path covered.

A recording whose state is missing at the start or at any step, or does not follow from its calls, makes recordingHolds false, and the page shows requests only.

The Go frame at split_window is now two panes with the geometry tmux gave them:

Frame 3 of the Go preview
{
  "done": 3,
  "tool": "split_window",
  "recorded": true,
  "sessions": [
    {
      "id": "$0",
      "name": "checks",
      "windows": [
        {
          "id": "@0",
          "index": 0,
          "name": "sh",
          "active": true,
          "layout": "{\"V\":2,\"L\":{\"t\":\"h\",\"w\":80,\"h\":24,\"x\":0,\"y\":0,\"c\":[{\"t\":\"p\",\"w\":40,\"h\":24,\"x\":0,\"y\":0,\"a\":true,\"i\":0,\"I\":\"%0\"},{\"t\":\"p\",\"w\":39,\"h\":24,\"x\":41,\"y\":0,\"i\":1,\"I\":\"%1\"}]}}",
          "panes": [
            {
              "id": "%0",
              "command": "sh",
              "left": 0,
              "top": 0,
              "width": 40,
              "height": 24,
              "active": true
            },
            {
              "id": "%1",
              "command": "sh",
              "left": 41,
              "top": 0,
              "width": 39,
              "height": 24,
              "active": false
            }
          ]
        }
      ]
    }
  ]
}

Julia and the #248 review items

  • The stand-in note now says that a real call sends the generation list_panes returned and that 3f9a1c itself is refused as stale_target. The claim is measured: after the example the recorder sends the stand-in as written, requires the server to refuse it, and stores which tool listed the generation and the refusal code. A recording without them is refused.
  • The recorder's stand-in mapping moved into standInCalls so it can be imported and tested: only the calls that carry the stand-in are rewritten, nothing is sent with the stand-in when no result listed a value, and a fake server shows the stand-in alone is refused as stale_target.
  • The linked-pane replay test is retitled to the first link and now links one pane into two places, requiring it to be drawn in the first only.
  • The four conflicts in gen-mcp-transcript.mjs are resolved as specified: both import lists, withListed inside Pin Ruby, TypeScript and Rust at their masters, re-record their MCP sessions, list the APIs Ruby's send-keys uses #247's settle-and-retry loop with listed computed after it, and julia: ['cat'] in PANE_COMMANDS.

Snapshots and recordings

Seven protocol snapshots were behind their ports' master heads, so they were refreshed first (with their mcp-tools.json rows); ts, rs and julia were already at master. Every port was then recorded at its current master head, on tmux 3.8a, under heavy.sh two at a time, from clean clones that are deleted afterwards:

Port Recorded at
py libtmux-mcp 800aebd26
ts 073b9d7de
rs 6942c1e60
go f59af95c2
java fe25ff5ed
csharp libtmux-dotnet 486830df1
cxx 166ce2c74
swift 3fd9dfe3d
ruby 0bf78344f (re-recorded when Ruby's pin moved; see below)
julia c19e4a164

For each, contracts equal the snapshot, the steps equal EXAMPLES, and recordingHolds passes. Each was recorded twice from the same checkout and compared except for volatile values; all ten match. The only differences are command durations, process ids and creation times, a server's generation and capture ids, and Swift's opaque refs. None is in the tmux state, which was identical in both recordings of every port. --check passes for Go, and refuses a Go recording with one step's state removed.

cxx was built with the repository's gcc preset, which this host's clang cannot link, and swift needs a libxml2.so.2 link on LD_LIBRARY_PATH; both were already the recorder's own workarounds. Free disk stayed above 240 GiB throughout.

Verification

  • The whole site suite, outer files included, after the review follow-ups: 8415 tests passed, 352 skipped and 6 failed. The six failures are port-landing and core-capabilities tests that read .github/workflows files from the local libtmux-ts and libtmux-rs checkouts at their pinned revisions, which those checkouts do not hold. They fail the same way on the head of Pin Ruby, TypeScript and Rust at their masters, re-record their MCP sessions, list the APIs Ruby's send-keys uses #247.
  • astro check: 0 errors and 0 warnings. oxlint reports nothing new in the touched files.
  • The new tests read a real tmux on a private socket when tmux is installed, and skip otherwise.
  • Not run: a site build, the publication audit, the full pnpm test outer loop, and the preview in a browser. The preview was checked through the component tests.
  • mcp-exports.test.ts expected every Go markdown twin to cite the pinned revision. A tool page links its source at the pinned revision and the listings cite the extracted one, which differ once a snapshot is at master, so the test now expects each.

Review follow-ups

An independent review asked for changes. Each is fixed in its own commit after the first push, and each has a test that fails without it.

The leak guard now reads escaped and encoded paths

The recorder scanned JSON.stringify(transcript) for \/tmp\/x, but stringifying that text writes \\/tmp\\/x, so the guard could never match a path a server escapes. Its test called leaks on a bare string and proved nothing about the recorder's call. Planting an escaped sandbox root, a triple-escaped one, a percent-encoded sandbox path, a percent-encoded checkout and {"dir":"/tmp","name":"t239-…"} in a tmux-state window name and running the old call on them found none of the five.

hostLeaks is now the one call the recorder makes. It walks every parsed string of the transcript without contracts, keys included, and reads each through layers of JSON escaping and percent-encoding in any order, and reads JSON held in a string. The serialized text is scanned as a second layer with the same readings. The sandbox's unique directory name is found wherever it appears, which finds the sandbox in a split path, and two sibling strings (a directory and a name in one object, or neighbouring entries of a list) are read as one path. A path split over three values, or in another encoding such as base64, is not found; the code says so.

The tests run the recorder's own steps (the rewrite, then hostLeaks) on a full transcript and plant each form into the window name of states of every committed recording. Removing any one decoder or heuristic fails a test, and a set of clean forms (the fixed project path with escaped and encoded spellings, the generation token, \nd) passes.

Ruby's window name

tmux new-session -d -s docs /bin/cat leaves the window named tmux until tmux renames it, and it renames only after the pane prints something. In 30 starts of that command, 21 left the name tmux at the first read, and the old settle check, which read only pane commands, would have recorded it in all 21. Both sides are fixed: the borrowed session is started with -n cat, and settledState refuses a window that tmux still names itself whose name differs from its active pane's program. With the new settle check alone, all 21 are refused; with -n cat, none of 30 starts is. Ruby was re-recorded three times. The tmux state is identical in all three and in the committed recording, and the files differ only in the server's generation, process ids, start time, capture ids and read times.

A settle timeout records again

settledState throws inside recordCalls, outside the retry's try, so a timeout ended the run although the docs said it records again. The loop is now recordSettled: a timeout while the calls run, or a failed check afterwards, resets the sandbox's servers and records again, bounded by ATTEMPTS, and any other error ends the run at once. A borrowed session is started inside each attempt, so Ruby and Julia retry like the others.

The stand-in refusal must be stale_target

The recorder and recordingHolds accepted any refusal code. Both now require STALE_TARGET, defined once in mcp-examples.ts; a recording that records another code, or none, is not shown.

Smaller fixes

  • runTmux kills tmux after 10 s and reports that it did not answer, and such a read fails rather than counting as an empty tmux. A test runs a fake tmux that never answers.
  • The sandbox guard resolves each socket with realpath and requires the real path inside the sandbox, runs tmux on the real path, and stopSandboxServers stops only servers of the sandbox. Tests use a link to a real socket outside the sandbox and a tmux directory that is itself a link, which the old startsWith check followed.
  • Two planted changes survived the tests and now fail them: dropping the "every pane runs the expected shell" condition from settledState (two equal reads of a tmux pane must be refused), and removing capture_pane from READ_ONLY_TOOLS. The unchanged-frame and caption tests no longer take their list of reading tools from the code they test, and a test pins the set.
  • The provenance line no longer says the whole preview is tmux's own state: layout and panes come from the recording's tmux server, and a pane's output from the results of the calls.

Recordings and verification

In the review round, only Ruby's stored data changed, so only Ruby was re-recorded. Go and Julia were run with --check from clean clones at their recorded revisions through the new recorder and passed, including Julia's stale_target control and borrowed session. The new guard finds nothing in any of the ten committed recordings, in the parsed values or the serialized text, scanned for the home directory, the temporary directory, the sandbox prefix, every port checkout and the machine and user names.

The whole site suite ran with 8415 passed, 352 skipped and 6 failed: the same six port-landing and core-capabilities failures as before, which read workflow files the local libtmux-ts and libtmux-rs checkouts do not hold at their pinned revisions. astro check reports 0 errors and 0 warnings. A site build, the publication audit and the browser checks were not run.

Left for later

Left for later (low severity):

  • --check compares the stored tmux state only with itself (scripts/gen-mcp-transcript.mjs), never with the state it just recorded.
  • The preview draws only the first session (windowOf uses sessions[0]); no recording has a second one, and nothing forbids it.
  • The host-name token check misses a name followed by . (tonybox.local) and uppercase variants (pre-existing).

Ruby pin and the three held programs

libtmux/libtmux-ruby#93, merged as 0bf78344f, made the three Ruby programs #247 held back publishable: examples/api/workspace_apply.rb writes the workspace file it loads, and version 2 of examples/manifest.json lets a program list the tmux capabilities it needs, with the docs export giving each API page its release in examples.tmux_releases (3.7 for control.rb and async_control.rb, 3.3a for the rest).

  • ruby-examples.ts states that release in each page's intro ("tmux 3.7 or newer") and shows the three programs. An export without tmux_releases, as every earlier Ruby release produces, keeps 3.3a and keeps the three programs held, so versioned pages of v0.1.0 and v0.1.1 do not change.
  • Ruby's pin moves from 20744401e to 0bf78344f with everything it regenerates: the model (55 pages carry a program, up from 44), the example cache, source guides, product copy, workspace showcase, manifest packages, tmux usage, and the MCP snapshot, tool rows and recording. 0bf78344f declares the gems at 0.1.1, so the pages install 0.1.1; the 0.1.2 release commits after it change only versions and changelogs.
  • The receipt adds the eleven pages the hold removed, each program run as displayed: workspace_apply.rb on tmux 3.3a and 3.7c; control.rb and async_control.rb on 3.7 and 3.7c, and refused on 3.3a.
  • The api-model suite passes (1332 tests); twelve of its new tests fail without the change, and the default-holds test fails when extractRuby holds nothing by default. ruby-api-examples.test.ts passes (60) and fails against the previous model and receipt.

tony added 14 commits November 4, 2026 03:23
why: A recording counts only against the server its snapshot describes,
and recording Julia needs the snapshot to be at the revision the
recorder runs. The committed snapshot described libtmux-julia
0.1.0-alpha.1; master reports 0.1.1, with new tool descriptions and
annotations, an `output` field in the error schemas, and a larger
list_panes input.

what:
- Regenerate mcp-protocol/julia.json with gen-mcp-protocol.mjs
  --port julia at libtmux-julia c19e4a164 (master)
- Regenerate Julia's entry of mcp-tools.json with gen-mcp-tools.mjs
  --port julia, because mcp-reference.test.ts binds the catalog to the
  snapshot; no other port's entry changes
why: Julia's worked example showed "Requests only" because the recorder
had no entry for it. Its server also differs from the recorded ports:
it borrows a running tmux, and a pane target carries a `generation`
(the tmux server's process id and start time) that list_panes returns
and capture_pane must send back. The example's stand-in is refused as
`stale_target`, and a real value differs on every run, so the call
could be neither recorded as written nor kept stable.

what:
- Add a julia entry that installs both packages from the checkout into
  an environment, writes the launcher `install_cli` documents, and
  borrows a tmux whose first pane shows checks.log
- Let a call's arguments be a function of the earlier steps, with
  `listedValue` and `withListed` to send the listed value in place of
  the example's stand-in; the step records what was sent
- Store the stand-in in the transcript and in `rewritten.generation`,
  and refuse a recording that still holds the real value
- Require Julia's last answer to state the log's last line
- Test that a call is sent the listed value, that the stand-in is
  refused by a server that checks it, and that a call with nothing
  listed fails instead of going out with the stand-in
why: The Julia page showed "Requests only" because its MCP worked
example had never been recorded. The recording alone would still have
drawn an empty tmux, since Julia's list_panes names the session and
window through `contexts` and the pane through `target`, which the
replay did not read, and a capture's screen went unshown.

what:
- Add mcp-transcripts/julia.json, recorded at libtmux-julia c19e4a164
  (master) by gen-mcp-transcript.mjs --port julia; its `contracts`
  equal the committed snapshot's, its steps equal EXAMPLES.julia, and
  re-recording produced the same bytes
- Draw a pane from a listing that carries `contexts` and `target`, and
  show what capture_pane read as the pane's screen
- Say on the page that the server's generation, which differs on every
  run, is shown as the stand-in the example uses
- Add Julia to the recorded-results, earlier-revision and replay tests,
  so a changed schema or argument for list_panes or capture_pane hides
  the recording; cover the page note, the drawn pane and the linked
  listing
why: The replay test for a listing whose panes carry the sessions and
windows they are linked into was titled "a pane for each link it has",
but the replay draws a pane at the first link only, and the test linked
each pane once. A pane linked into two places was never exercised, so
the title claimed more than the test checked.

what:
- Retitle the test to say the first link places the pane
- Link one pane into two sessions and require that it is drawn in the
  first only, and that the order of the links decides which
why: The recorder swaps Julia's example generation, 3f9a1c, for the one
list_panes returned, but the mapping that decides which calls are
rewritten sat inline in gen-mcp-transcript.mjs, where no test could
reach it. Nothing showed either that only the calls carrying the
stand-in are rewritten, or that the server refuses the stand-in itself
as stale_target, which is why a real call must send the listed value.

what:
- Move the mapping into scripts/lib/mcp-session.mjs as standInCalls and
  use it from the recorder
- Test that a call carrying the stand-in is sent the listed value and
  the step records what went out, that other calls are untouched, that
  a call is never sent with the stand-in when nothing listed a value,
  and that an example without a stand-in is unchanged
- Give the fake server's refusal the stale_target code and add the
  control that the example sent as written is refused with it
why: The preview drew only what each call returned. Go's split_window
returns {"paneId":"%1"} with no layout or size, so after the split the
preview deleted the old layout and had no geometry, and drew the split
only at the next list_panes, a step behind the call. Every port has the
same shape, so the recording has to hold what tmux looked like, not
only what the server said.

what:
- scripts/lib/mcp-tmux-state.mjs reads the recorder's own sandbox tmux
  on its socket: the sessions, the windows with the layout tmux reports,
  and the panes with their command, rectangle and active flag. It
  refuses a socket outside the sandbox and a sandbox running two
  servers, reads a sandbox with no server, or a server with no session,
  as an empty tmux, and fails on any other read error
- The state of a step is read once the pause that stands for an agent
  reading the result has passed, and again until two reads agree and
  every pane runs the expected shell, because a pane is named tmux until
  its shell replaces it and a window is renamed for the program a pane
  runs. A state that does not settle is the unsettled-pane error, which
  records again
- recordCalls reads the state before the first call and after each one,
  stores it as tmuxBefore and as tmux on the step, and ends the session
  on a read error, so a step without a state is never written
- site/src/lib/mcp-tmux-state.ts holds the shape and the checks that the
  recorder and the page share: each part refers to the others, one
  window and one pane are active, a layout names its panes at the
  rectangles they report, a call names only ids the state before it
  had, a result returns only ids the state after it has, and a tool
  that only reads leaves tmux as it was
- verifyRecording requires those checks, and --check refuses a stored
  recording that lacks them
- Julia's stand-in generation is checked, not assumed: a control call
  with the stand-in is made after the example and must be refused, and
  the recording stores which tool listed the generation and the code
  of the refusal
- sanitize and leaks also handle a path whose slashes a server escapes
  in its JSON text. Swift wrote the sandbox's project path that way, so
  its recording named a random sandbox directory that the rewrite and
  the leak scan missed and that changed on every run

Tests read a real tmux on a private socket, skipped when tmux is absent,
and a fake one for the refusals.
why: A recording counts only against the server its snapshot describes,
and the recorder refuses a checkout that is not at the snapshot's
revision. The ports below have moved on since their snapshots were
taken, so recording their worked examples at master needs the snapshots
at master first.

what:
- gen-mcp-protocol.mjs --port <port> from clean clones at master:
  libtmux-mcp (py) 800aebd26, libtmux-go f59af95c2, libtmux-java
  fe25ff5ed, libtmux-dotnet 486830df1, libtmux-cxx 166ce2c74,
  libtmux-swift 3fd9dfe3d and libtmux-ruby c860ae584. ts, rs and julia
  were already at master. cxx was built with the repository's gcc
  preset, which this host's clang cannot link
- gen-mcp-tools.mjs --port <port> for the same ports, because
  mcp-reference.test.ts binds the catalog to the snapshots; their rows
  now cite the revision they were extracted from
- mcp-exports.test.ts expected every Go markdown twin to cite the pinned
  revision. A tool page links its source at the pinned revision and the
  listings cite the extracted one, which differ once a snapshot is at
  master, so the test now expects each
why: The MCP preview drew only what the calls returned. After Go's
split_window, whose result is {"paneId":"%1"}, it showed "Panes, sizes
not read yet" and drew the split at the next list_panes, whose caption
said tmux itself was unchanged: a step behind. Every port has the same
shape. Julia's page also named its stand-in generation without saying
that a real call sends the one list_panes returned.

what:
- replayFrames draws frame i from the state recorded after call i and
  frame 0 from the state before the first call. The caption reads "tmux
  after <tool>", and "unchanged, the call only reads" after a tool that
  only reads. A pane shows what the calls typed into it and read from
  it, as before. A recording that stores no state keeps drawing from
  its results
- recordingHolds refuses a recording whose state is missing at the start
  or at any step, or does not follow from its calls, so the page shows
  requests only instead of a misleading preview
- The provenance line says the preview shows the recording's own tmux
  server after each call
- Julia's note says a real call sends the generation list_panes
  returned and that 3f9a1c itself is refused as stale_target. The
  recorder measures the refusal, and a recording without it is refused
- Re-record every port at its master head with the new recorder: py
  800aebd26 (libtmux-mcp), ts 073b9d7de, rs 6942c1e60, go f59af95c2,
  java fe25ff5ed, csharp 486830df1 (libtmux-dotnet), cxx 166ce2c74,
  swift 3fd9dfe3d, ruby c860ae584 and julia c19e4a164. Each contracts
  entry equals its snapshot and each recording's steps equal EXAMPLES.
  Each port was recorded twice from the same checkout; the two files
  differ only in command durations, process ids and times, a server's
  generation and capture ids, and Swift's opaque refs
- Tests: the Go-shaped split_window frame draws two panes with the
  geometry tmux gave them; every port has a state for its start and
  each step; frame i equals the recorded state of each port; a read
  leaves the state unchanged; a missing, late or mismatched state is
  refused; a path, user name or machine name planted in a state is
  found by the leak scan and removed by the rewrite
why: The recorder scanned JSON.stringify(transcript), which writes
`\/tmp\/x` as `\\/tmp\\/x`. The guard looked for `\/tmp\/x`, so it never
matched a path a server escapes, and it missed percent-encoded paths,
triple-escaped paths and a path split into fields. Its test called
`leaks` on a bare string, so it never ran the recorder's call.

what:
- `readings` undoes layers of JSON escaping and percent-encoding in any
  order, and `leaks` looks for each place, unique name and person's name
  in every reading
- `recordingLeaks` walks every parsed string of the transcript, keys
  included, decodes each one, and reads JSON held in a string. Two
  sibling strings (a directory and a name) are also read as one path
- `hostLeaks` is the one call the recorder makes: the walk, then the
  serialized text as a second layer, with the sandbox's unique directory
  name found wherever it appears. A path split over three values, or
  written in another encoding such as base64, is a stated limit
- Tests run the recorder's own steps (the rewrite, then `hostLeaks`) on
  a full transcript and plant an escaped sandbox root, a triple-escaped
  one, percent-encoded paths, the unique directory name, split fields
  and encoded names into the window name of states of every committed
  recording; each is found. Controls: the fixed project path, its
  escaped and encoded forms, the generation token and `\nd` pass. Each
  heuristic fails a test when removed
why: A borrowed session started with `tmux new-session -d -s docs
/bin/cat` leaves its window named `tmux` until tmux renames it, and it
renames only after the pane prints something. The settle check read only
pane commands, so the recorder could write that name. runTmux had no
timeout, so a tmux that did not answer blocked the event loop. The
socket guard checked the name of a socket after a stat that follows
links, so a link inside the sandbox to a reader's server passed.

what:
- The window listing reads `automatic-rename`; `settledState` refuses a
  window tmux still names itself whose name differs from its active
  pane's program, and returns the state without that note
  (`recordedState`)
- `runTmux` kills tmux after 10 s and says it did not answer; such a
  read is an error, not an empty tmux
- `ownSockets` resolves each socket with realpath and requires the real
  path inside the sandbox; sockets and links are listed so a link out
  is refused. `readTmuxState` runs tmux on the real path, and
  `stopSandboxServers` stops only servers of the sandbox
- Tests: two equal reads of a `tmux` pane are refused until the
  deadline; a window called `tmux` over a `cat` pane is waited for,
  refused when it stays, and compared with its own active pane; links
  out of the sandbox and out of its tmux directory are refused on a
  real file system; a hung tmux is killed at the timeout; a real tmux
  reports a named window as explicit
why: The docs said an unsettled state records again, but the timeout is
thrown inside recordCalls, outside the loop's try, so it ended the run.
The stand-in generation was also accepted when the server refused it
with any error code, though the page tells the reader it is refused as
`stale_target`.

what:
- `recordSettled` retries when `record` throws an unsettled-pane error
  while the calls run, or `check` throws one afterwards, resets between
  attempts, throws with the count after the last, and passes any other
  error through at once
- `requireRefusal` requires the code the page documents; the code is
  `STALE_TARGET` in mcp-examples.ts, shared by the recorder and
  `recordingHolds`, which now hides a recording that records another
  code
- Tests: a timeout while the calls run is retried, as is a failed
  check; the retries are bounded; another error is not retried; an
  asynchronous reset is awaited; a refusal that is not `stale_target`,
  and one that carries no code, are refused by both
why: The recorder still held its own copies of the loop, the leak call
and the server stop, so the fixes above did not reach it. A borrowed
session could not be started again, so a window left named `tmux` ended
the run on the first attempt, and 21 of 30 starts left it so.

what:
- Record through `recordSettled`; a borrowed session is started inside
  each attempt, after the sandbox's servers were stopped, so Ruby and
  Julia retry like the others
- Start the borrowed session with `-n` and its program, so tmux names
  the window at creation and not after the pane prints
- Scan the transcript with `hostLeaks`, require the stand-in's refusal
  with `requireRefusal`, and stop servers with `stopSandboxServers`
why: Ruby's borrowed session started with the window named `tmux` in
some starts. The recorder now names the window at creation and refuses
one that is not named for its pane's program.

what:
- Re-record Ruby at the revision it already records. The tmux state is
  identical to the committed one in three recordings; only the server's
  generation, process ids, start time, capture ids and read times
  differ
why: The provenance line said the preview shows the recording's own
tmux server after each call. The layout and the panes do, but a pane's
output comes from the results of the calls. The test that checked
which calls only read took its list from the code it tested, so
dropping a tool from READ_ONLY_TOOLS passed.

what:
- The line says the layout and panes come from the recording's own tmux
  server after each call and a pane's output from the calls' results
- The unchanged-frame and caption tests name the reading tools
  themselves, and a test pins the set `READ_ONLY_TOOLS` holds
- An existing test that forbade the word "output" in the provenance
  forbids "output schema", which is what it meant
@tony
tony force-pushed the feat/mcp-tmux-state-20261103 branch from 80dfe2a to 9aeaa8b Compare November 4, 2026 09:30
@tony
tony changed the base branch from fix/ruby-core-showcase-refresh-20261103 to main November 4, 2026 09:30
tony added 2 commits November 4, 2026 04:49
why: The publication audit checks out libtmux-ruby at the revision of
Ruby's API model and binds the MCP catalog to that checkout, so a Ruby
snapshot taken at master failed the tool matrix step on every head of
this branch. Ruby is the one MCP port the audit checks out; the others
have no checkout there and skip the comparison. Ruby's master c860ae584
differs from the pin 20744401e only in .github/workflows/docs.yml, so
the tool schemas are the same at both and the worked example recorded
at master still holds.

what:
- site/src/data/mcp-protocol/ruby.json returns to main's snapshot at
  20744401e; only its revision differed.
- The Ruby rows of mcp-tools.json cite 20744401e as the revision they
  were extracted from, regenerated by gen-mcp-tools.mjs --port ruby
  against a checkout at the pin.

verification: gen-mcp-tools.mjs --check, run with checkouts of Ruby and
Lua at their pins and no others, as test-all runs it, fails before this
commit naming ruby and passes after; it passes on main. The MCP test
files pass.
why: kill-server returns before the server process exits. A server told
to exit stays up until its last client has gone, and drops every client
that connects meanwhile with "server exited unexpectedly". The reader
treated that as a failure to read, so the real-tmux test that polls
after kill-server failed in CI on the previous head (run 41816772630,
"Keep Ruby's protocol snapshot at its model's pin"). The recorder reads
through the same function after every step, including after a
kill_session that ends the server, so a recording could fail on the
same window.

what:
- NO_SERVER also matches "server exited unexpectedly", so runTmux marks
  it noServer and readTmuxState returns the empty state, as it already
  does for a socket with no server.
- A test drives runTmux with a fake tmux: that message is noServer, and
  an unrelated error ("protocol version mismatch") still is not.
- A real-tmux test holds the server in that window: a stopped control
  client keeps it up after kill-server, a read gets "server exited
  unexpectedly", and the reader returns the empty state.
- The reader's other-failure test uses the version-mismatch message
  instead, since the shutdown message now means a server that has gone.

verification: with a stopped control client, every list-sessions after
kill-server answered "server exited unexpectedly" on tmux 3.4 and 3.8a.
The MCP tmux-state test file passes on both; with the new pattern
removed, both new tests fail on both.
@tony
tony force-pushed the feat/mcp-tmux-state-20261103 branch from 36804ae to 6e66997 Compare November 5, 2026 00:13
tony added 2 commits November 4, 2026 21:24
why: Every Ruby API page stated tmux 3.3a. libtmux/docs held back
control.rb and async_control.rb, whose control client the library
refuses below tmux 3.7, and workspace_apply.rb, which loaded a file no
page showed (libtmux-ruby#92). Version 2 of libtmux-ruby's example
manifest lets a program list the capabilities it needs, and its docs
export gains `examples.tmux_releases`, the release each page states. An
export of an earlier Ruby release has no such map.

what:
- State the release the export gives a program ("tmux 3.7 or newer"),
  checked against the tmux release pattern. A program the map leaves
  out, a malformed release and a map that is not an object fail the
  build.
- Apply HELD_RUBY_PROGRAMS to an export without `tmux_releases` only. It
  keeps stating 3.3a and holding the three programs, and a hold naming a
  program the manifest does not list still fails there.
- Cover a version 2 export that states 3.7 for control.rb and lists all
  three programs, a version 1 export that holds them at 3.3a, malformed
  releases, and extractRuby's default holds for an export without
  releases. Twelve of the new tests fail without the change, and the
  default-holds test fails when extractRuby holds nothing by default.
why: Three complete programs stayed off their Ruby reference pages.
workspace_apply.rb loaded a file no page showed, and control.rb and
async_control.rb need tmux 3.7 while every page stated 3.3a.
libtmux-ruby #93, merged as 0bf78344f, fixed both: the example
manifest names each program's tmux capabilities, and the docs export
carries the release each page states. The model still cited 20744401e,
which has neither. 0bf78344f declares the gems at 0.1.1, so the pages
install 0.1.1; the 0.1.2 release commits after it change only versions
and changelogs.

what:
- Regenerate the Ruby model from the committed bytes of 0bf78344f with
  `gen-api-model --port ruby`. No symbol is added or removed, and none
  changes its signature, source line or documentation. The eleven
  symbols of the three programs gain their four example blocks, so 55
  pages carry a program, up from 44, and every example cites the new
  revision. control.rb and async_control.rb state "tmux 3.7 or newer";
  the other sixteen programs keep 3.3a.
- Regenerate what is bound to the Ruby pin: the example cache (its
  manifest, now version 2, and test/support/installed_gems.rb), the
  product copy, the workspace showcase, the source guides (the
  libtmux-workspace README quotes the self-contained program), the
  manifest packages entry, the tmux usage record, and the MCP protocol
  snapshot, tool rows and recording, whose steps, arguments and
  contracts equal the previous recording's.
- Rebind the receipt's 44 entries to 0bf78344f and add the eleven the
  hold had removed, each run as displayed with check-example-prose.py:
  workspace_apply.rb printed its three lines on tmux 3.3a and 3.7c;
  control.rb and async_control.rb printed theirs on tmux 3.7 and 3.7c
  and stop on 3.3a with "control connections require tmux 3.7 or
  newer".
- The site tests check that the manifest at the model's revision is
  version 2 with only the control programs listing a capability, that
  each of the three programs is shown on every page it documents with
  the release its capabilities open, and that the receipt lists those
  releases. They fail against the previous model and receipt.
@tony
tony force-pushed the feat/mcp-tmux-state-20261103 branch from f9afeab to 873fc3f Compare November 5, 2026 03:25
@tony
tony marked this pull request as ready for review November 5, 2026 07:11
@tony
tony merged commit d3b931b into main Nov 5, 2026
3 checks passed
@tony
tony temporarily deployed to docs-preview-cleanup November 5, 2026 07:12 — with GitHub Actions Inactive

This branch was previously deployed

2 inactive deployments
docs-preview-cleanup — 873fc3fe Deployed Nov 5, 2026 by tony via cleanup #209
docs-preview — 873fc3fe Deployed Nov 5, 2026 by tony via publish-preview / publish #1170
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.

1 participant