diff --git a/docs/internals/tmux-behaviour.md b/docs/internals/tmux-behaviour.md index a03d2ebac..bf4c70851 100644 --- a/docs/internals/tmux-behaviour.md +++ b/docs/internals/tmux-behaviour.md @@ -93,6 +93,16 @@ through a client's key table and a clock takes no such commands. `display-panes` and `display-popup` require an attached client on every lane and are not exposed for that reason, the same as `new-session -A` above. +## A pane is named `tmux` until its shell starts + +Between fork and exec the process in a pane's terminal is the server's own +copy, so `#{pane_current_command}` reads `tmux`. Reading it straight after +`new-session -d` returned `tmux` in 3 of 150 attempts on tmux 3.8a, one tmux +invocation per read on an idle host, and a loaded CI runner widens the window. +A caller that must know the pane's program, as `Pane.run` and the MCP +`run_shell_command` must to check for a POSIX shell, waits for another name +(`PaneCommand.awaitShellStart`) rather than trusting the first read. + ## run-shell output is missing on 3.3a and 3.4 only Reading what `run-shell` printed silently reports nothing on 3.3a and 3.4, and diff --git a/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java b/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java index 6a71463da..75f68adbf 100644 --- a/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java +++ b/integration-tests/src/test/java/io/github/libtmux/it/PaneRunIntegrationTest.java @@ -12,11 +12,14 @@ import io.github.libtmux.TextOutcome; import io.github.libtmux.Window; import io.github.libtmux.junit5.TmuxExtension; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.OptionalInt; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; /** * Running a command in a pane to its end: the status is the shell's, and the output is only the @@ -127,6 +130,31 @@ void aCommandStillRunningAtTheDeadlineIsATimeout(Server server) throws Interrupt assertEquals(List.of("started"), ran.output(), "what it had printed by the deadline"); } + /** + * Between fork and exec the process in a pane's terminal is the server's own copy, and tmux + * names the pane {@code tmux}. A command run in a pane created a moment ago must wait for the + * shell to start, not refuse the pane as a program that is not one. + */ + @Test + void aPaneThatHasNotStartedItsShellYetIsWaitedFor(Server server, @TempDir Path directory) throws Exception { + Path named = Files.createSymbolicLink(directory.resolve("tmux"), Path.of("/bin/sh")); + Pane pane = server.newSession(session -> session.named("forking") + .running(named.toString(), "-c", "sleep 0.7; exec env PS1='$ ' ENV= /bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + assertEquals( + "tmux", + pane.variables(List.of("pane_current_command")).get("pane_current_command"), + "the pane still carries the name of the process that has not exec'd yet"); + + PaneRun ran = pane.run("printf 'started\\n'", GENEROUS); + + assertEquals(List.of("started"), ran.output()); + assertEquals(OptionalInt.of(0), ran.exitStatus()); + } + @Test void aPaneNotRunningAShellIsRefused(Server server) { Pane pane = server.newSession(session -> session.named("not-a-shell").running("sleep", "60")) diff --git a/libtmux-mcp/CHANGELOG.md b/libtmux-mcp/CHANGELOG.md index 4c95ad78f..cbd9751bb 100644 --- a/libtmux-mcp/CHANGELOG.md +++ b/libtmux-mcp/CHANGELOG.md @@ -14,6 +14,13 @@ changelog](../CHANGELOG.md). ## Unreleased +### Fixed + +- **`run_shell_command` waits for the shell of a pane created a moment ago.** + tmux names such a pane `tmux` until its shell starts, and the tool refused it + as not running a POSIX shell. It now waits up to two seconds for another + name. A pane still named `tmux` after that is refused as before. (#156) + ## 0.1.2 — 2026-11-01 No entries for `libtmux-mcp`. diff --git a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java index 6ced58c0a..f96491847 100644 --- a/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java +++ b/libtmux-mcp/src/main/java/io/github/libtmux/mcp/RunningCommands.java @@ -81,6 +81,14 @@ static Ran run(Call call) { boolean suppressHistory = call.flag("suppress_history", true); PaneInputCohort.Resolution initial = PaneInputCohort.resolve(pane, call.caller()); String currentCommand = initial.requireSingularCommandPane("run_shell_command"); + try { + if (PaneCommand.awaitShellStart(pane, currentCommand)) { + initial = PaneInputCohort.resolve(pane, call.caller()); + currentCommand = initial.requireSingularCommandPane("run_shell_command"); + } + } catch (InterruptedException cancelled) { + throw Waits.cancelled(cancelled); + } requirePosixShell(currentCommand); PaneInputReservations.Lease lease = PaneInputReservations.run(pane, initial, "run_shell_command"); boolean retained = false; diff --git a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java index 4883ec6f9..cfd740d5c 100644 --- a/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java +++ b/libtmux-mcp/src/test/java/io/github/libtmux/mcp/RunningCommandsTest.java @@ -512,6 +512,32 @@ void aPaneNotRunningAPosixShellIsRefused(Server server) { "the rejected payload must not reach the foreground program"); } + /** + * Between fork and exec the process in a new pane is the server's own copy, and tmux names the + * pane {@code tmux}. A run in a pane created a moment ago waits for the shell to start rather + * than refusing the pane as another program. + */ + @Test + void aPaneThatHasNotStartedItsShellYetIsWaitedFor(Server server, @TempDir Path temporary) throws Exception { + Path named = Files.createSymbolicLink(temporary.resolve("tmux"), Path.of("/bin/sh")); + Pane pane = server.newSession(session -> session.named("forking") + .running(named.toString(), "-c", "sleep 0.7; exec env PS1='$ ' ENV= /bin/sh")) + .windows() + .get(0) + .panes() + .get(0); + assertEquals( + "tmux", + pane.variables(List.of("pane_current_command")).get("pane_current_command"), + "the pane still carries the name of the process that has not exec'd yet"); + + RunningCommands.Ran ran = RunningCommands.run( + TestCalls.on(server, "pane_id", pane.id().value(), "command", "printf 'started\\n'", "timeout", 20)); + + assertCompleted(ran, 0); + assertEquals(List.of("started"), ran.output()); + } + /** * The reason this tool exists rather than send-then-look: a failure is a number, not something to * infer from what the screen says. diff --git a/libtmux/CHANGELOG.md b/libtmux/CHANGELOG.md index 0d948192a..9c9f67ad2 100644 --- a/libtmux/CHANGELOG.md +++ b/libtmux/CHANGELOG.md @@ -22,6 +22,10 @@ changelog](../CHANGELOG.md). - **Control output preserves pane bytes and supports incremental text decoding.** Subscribers can distinguish output gaps, connection closure and transport failures while retaining the original termination cause. (#20) +- **`PaneCommand.awaitShellStart(pane, current)` waits for the shell of a pane + created a moment ago.** It waits only when `current` is `tmux`, up to two + seconds, and returns whether it waited, so the caller reads the pane's command + again. `Pane.run` and the MCP server's `run_shell_command` use it. (#156) ### Changed @@ -31,6 +35,14 @@ changelog](../CHANGELOG.md). [migration notes](../MIGRATION.md#output-subscriptions-bound-payload-bytes). (#20) +### Fixed + +- **`Pane.run` waits for the shell of a pane created a moment ago.** tmux names + such a pane `tmux` until its shell starts, and `Pane.run` refused it with + `IllegalStateException` on the first read. It now waits up to two seconds for + another name. A pane still named `tmux` after that is refused as before. + (#156) + ## 0.1.2 — 2026-11-01 No entries for `libtmux`. diff --git a/libtmux/src/main/java/io/github/libtmux/Pane.java b/libtmux/src/main/java/io/github/libtmux/Pane.java index 17696aac1..5356fc9ca 100644 --- a/libtmux/src/main/java/io/github/libtmux/Pane.java +++ b/libtmux/src/main/java/io/github/libtmux/Pane.java @@ -448,7 +448,8 @@ public TextWait watchText(String text, Duration timeout, Duration every) throws * *

It runs in the shell a person set up — its directory, its environment, a virtualenv someone * activated — which is the reason to type it there rather than start a process of your own. The - * pane has to be running a POSIX shell for the typed line to mean what it says. + * pane has to be running a POSIX shell for the typed line to mean what it says. A pane created a + * moment ago, whose shell has not started yet, is waited for briefly. * *

A command still running at the deadline keeps running; the answer carries what it had printed * by then, and {@link PaneRun#exact()} is false. @@ -466,8 +467,13 @@ public PaneRun run(String command, Duration timeout) throws InterruptedException Objects.requireNonNull(timeout, "timeout"); // What the pane is running and where its server listens, read together: one command, and the // same moment, so the shell checked is the shell the line is typed into. - Map here = variables(List.of("pane_current_command", "socket_path")); + List read = List.of("pane_current_command", "socket_path"); + Map here = variables(read); String running = here.getOrDefault("pane_current_command", ""); + if (PaneCommand.awaitShellStart(this, running)) { + here = variables(read); + running = here.getOrDefault("pane_current_command", ""); + } PaneCommand.requirePosixShell(running.isEmpty() ? state.currentCommand() : running); PaneCommand frame = PaneCommand.fresh(); List tmux = List.of(server.config().binaryPath(), "-S", here.getOrDefault("socket_path", "")); diff --git a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java index 28ec662a2..8ae025667 100644 --- a/libtmux/src/main/java/io/github/libtmux/PaneCommand.java +++ b/libtmux/src/main/java/io/github/libtmux/PaneCommand.java @@ -2,6 +2,7 @@ import io.github.libtmux.catalog.Advanced; import java.security.SecureRandom; +import java.time.Duration; import java.util.ArrayList; import java.util.HexFormat; import java.util.List; @@ -41,6 +42,12 @@ public final class PaneCommand { /** The shells whose syntax the typed line is written in. */ private static final Set POSIX_SHELLS = Set.of("sh", "ash", "bash", "dash", "ksh", "mksh", "pdksh", "zsh"); + /** What a pane reports between fork and exec, while its foreground process is still the server. */ + private static final String FORKED_SERVER = "tmux"; + + /** How long a pane named {@link #FORKED_SERVER} is given to exec its shell before it counts as running tmux. */ + private static final Duration EXEC_GRACE = Duration.ofSeconds(2); + private final String startMark; private final String endMark; private final String channel; @@ -203,6 +210,27 @@ public record Framed(@ReadOnly List lines, boolean exact, OptionalInt st } } + /** + * Waits for a pane that was just created to start its shell, and says whether it had to. + * + *

Between fork and exec the process in a pane's terminal is the server's own copy, so tmux + * names the pane {@code tmux} until its shell starts. A read of the pane's command taken in that + * window is out of date once this returns {@code true}, and has to be taken again. A pane still + * named {@code tmux} after the wait is left for the caller's shell check to refuse. + * + * @param pane the pane to wait for + * @param current the command the pane was last read to be running + * @return whether the pane was waited for, so that {@code current} is stale + * @throws InterruptedException if the waiting thread is interrupted + */ + public static boolean awaitShellStart(Pane pane, String current) throws InterruptedException { + if (!current.equals(FORKED_SERVER)) { + return false; + } + pane.await(fresh -> !fresh.currentCommand().equals(FORKED_SERVER), EXEC_GRACE); + return true; + } + /** Refuses a pane whose shell would not read the typed line as written. */ static void requirePosixShell(String current) { int slash = current.lastIndexOf('/');