Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/internals/tmux-behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down
7 changes: 7 additions & 0 deletions libtmux-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions libtmux/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.
Expand Down
10 changes: 8 additions & 2 deletions libtmux/src/main/java/io/github/libtmux/Pane.java
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,8 @@ public TextWait watchText(String text, Duration timeout, Duration every) throws
*
* <p>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.
*
* <p>A command still running at the deadline keeps running; the answer carries what it had printed
* by then, and {@link PaneRun#exact()} is false.
Expand All @@ -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<String, String> here = variables(List.of("pane_current_command", "socket_path"));
List<String> read = List.of("pane_current_command", "socket_path");
Map<String, String> 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<String> tmux = List.of(server.config().binaryPath(), "-S", here.getOrDefault("socket_path", ""));
Expand Down
28 changes: 28 additions & 0 deletions libtmux/src/main/java/io/github/libtmux/PaneCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,12 @@ public final class PaneCommand {
/** The shells whose syntax the typed line is written in. */
private static final Set<String> 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;
Expand Down Expand Up @@ -203,6 +210,27 @@ public record Framed(@ReadOnly List<String> lines, boolean exact, OptionalInt st
}
}

/**
* Waits for a pane that was just created to start its shell, and says whether it had to.
*
* <p>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('/');
Expand Down
Loading