Summary
libtmux-mcp can be invoked in four materially different situations, and it currently distinguishes none of them to the agent:
| # |
Scenario |
What the agent is told today |
| a |
Launched inside a tmux pane |
A prose line in instructions — which can be silently dropped under the byte cap, and names a socket the tools don't actually query |
| b |
Launched outside tmux, a tmux server is running |
Nothing. The Agent context line is simply absent, with no replacement |
| c |
Launched outside tmux, no server running |
Nothing. Discovery returns [], indistinguishable from "empty server" |
| d |
Outside tmux, no server, and the agent must not start one |
No such mode exists |
The only in-tmux detection happens once, at import, in _build_instructions — and it feeds nothing but a sentence. Nothing else in the process consumes it.
Separately: create_session silently spawns a long-lived tmux daemon (sourcing the user's tmux.conf) with no gate, no opt-in, and no signal in its response that it did so.
Evidence
Detection exists but is inert
server.py#L195 — _build_instructions is the sole startup reader of TMUX_PANE
server.py#L279 — the lifespan probe checks only that the tmux binary exists; it never probes whether a server does
server.py#L221 — no negative branch: with TMUX/TMUX_PANE unset the Agent context block is simply omitted. Verified. Agents in scenarios (b)/(d) must infer their context from an absence.
resources/hierarchy.py#L38 — every registered resource is a hierarchy read. Nothing exposes inside_tmux, effective socket, or server-running state.
"No server" is indistinguishable from "empty server"
Verified empirically against a dead socket: list_sessions/list_windows/list_panes → []; list_servers → []. libtmux swallows the daemon-not-up error and returns an empty QueryList:
get_server_info is the only discriminator (is_alive=False) — and nothing in the instructions tells the agent to call it first:
Worse, on the default socket it reports socket_name=None and socket_path=None even when alive — so it cannot even tell the agent which socket it just queried:
The error path is a dead-end loop
With no server running, capture_pane(pane_id="%0") fails with "Pane not found: %0" and the suggestion "Call list_panes to discover valid pane ids" — and list_panes returns [], because no daemon exists. The agent is sent in a circle.
And "no tmux server" is logged as an unexpected ERROR for set_environment, because libtmux raises a bare ValueError (not a LibTmuxException), missing the expected-error branch and landing in the catch-all with logger.exception:
A routine "no server running" thereby pollutes operator error budgets as a non-bug.
Auto-start is silent, ungated, and unannounced
I probed every plausible candidate against a dead socket (show_option, set_option, show_environment, set_environment, signal_channel, load_buffer, create_window) — none spawn a daemon. create_session is the only tool that does (is_alive False → True):
It is registered with ANNOTATIONS_CREATE (destructiveHint=False) in the plain mutating tier — so a default-profile agent will spawn a long-lived tmux daemon, sourcing the user's ~/.tmux.conf, as a routine "create":
SessionInfo carries no signal that a whole tmux server came into existence as a side effect — the agent and the audit log see an ordinary session create:
And there is no "do not start a server" mode: no start_server tool is registered, libtmux.Server.start_server() is never called anywhere in the package, and the only way to block an auto-start is LIBTMUX_SAFETY=readonly — which hides every mutating tool.
Reproduction
Scenario (c) — no server, dead-end loop:
tmux -L novel_socket kill-server 2>/dev/null; LIBTMUX_SOCKET=novel_socket <launch mcp>
list_panes() → []. 2. capture_pane(pane_id="%0") → "Pane not found: %0 … Call list_panes to discover valid pane ids." 3. list_panes() → []. Observed: a loop with no way out and no statement that the server is simply not running. Expected: "No tmux server is running on socket novel_socket."
Scenario (c) — silent daemon spawn: on that same dead socket, call create_session(session_name="x"). Observed: a tmux daemon is started (sourcing the user's config) and SessionInfo looks like any other create. Expected: either an explicit opt-in, or at minimum server_started: true in the response.
Suggested fix
1. Expose the context surface. This is the same tool proposed in the self-location issue — where_am_i (readonly) + a tmux://self resource — whose payload deliberately covers server/safety state as well as caller identity:
{
"inside_tmux": bool,
"pane_id": str | None, "window_id": str | None, "session_id": str | None,
"caller_socket_path": str | None,
"effective_socket_name": str | None, # what the tools will ACTUALLY query
"effective_socket_path": str | None, # (can differ from caller_* — see the socket bug)
"server_running": bool,
"live_server_count": int,
"safety_level": str, # readonly | mutating | destructive
"suppress_history": bool,
}
One call answers "where am I / is anything running / what will my tools target / what am I allowed to do". It replaces a prose suffix that vanishes silently under the 2048-byte instructions cap. The two issues should ship together — one tool, not two. (Naming rationale, including why get_context was rejected — "context" is hopelessly overloaded in agent-land — lives in that issue.)
Liveness must come from Server.is_alive() — one list-sessions, provably no daemon spawn (libtmux/server.py#L241). Do not infer liveness from socket-file existence: start-server leaves a stale socket file behind after the sessionless daemon exits under exit-empty on (libtmux/server.py#L1448). And do not infer it from emptiness of .sessions vs .panes — they swallow different exception sets.
2. Make server auto-start opt-in. Add allow_server_start: bool = False to create_session (default from LIBTMUX_ALLOW_SERVER_START), and register a dedicated start_server tool wrapping libtmux's existing Server.start_server(). Starting a tmux daemon sources the user's config and leaves a long-lived background process — that should be an explicit, auditable action, not a side effect of a create tagged destructiveHint=False.
(Caveat for whoever implements this: start_server() alone is insufficient — under exit-empty on the sessionless daemon exits immediately and is_alive() still returns False. A real "ensure server" must create a session.)
3. Announce the spawn. Add server_started: bool to SessionInfo so both the agent and the AuditMiddleware record can see that a create_session brought a whole daemon into existence rather than joining an existing one.
4. Give the not-found errors a real recovery hint. Have the targeted resolvers (_resolve_pane/_resolve_window/_resolve_session) probe server.is_alive() before raising, so the error names the actual cause:
"No tmux server is running on socket <name>. Call list_servers to find live sockets, or start_server / create_session(allow_server_start=true) to start one."
Also fix show_environment, which returns {"variables": {}} on a dead server — indistinguishable from a genuinely empty environment.
5. Map libtmux's bare ValueError from set_environment/unset_environment into the expected-error path in _map_exception_to_tool_error (or fix it upstream — see the libtmux issue), so "no tmux server running" stops being logged as an unexpected ERROR.
6. State the negative case in the instructions. When TMUX_PANE is absent, append an explicit segment rather than silently omitting the context block:
"This MCP is NOT running inside tmux: there is no 'self'. is_caller is null on every pane. Call where_am_i / list_servers first; ask the user which session to target rather than guessing."
Tests
where_am_i() outside tmux → inside_tmux=False, and server_running correctly reflects a live vs dead socket.
where_am_i() on a dead socket → server_running=False, live_server_count=0; on a live one → True.
create_session(allow_server_start=False) on a dead socket raises rather than spawning; assert no socket file appears.
create_session(allow_server_start=True) on a dead socket returns SessionInfo(server_started=True).
capture_pane on a dead server produces an error naming "no tmux server", not "Pane not found → call list_panes".
set_environment on a dead server raises an expected ToolError, and is not logged via logger.exception.
- Instructions assertion: with
TMUX_PANE unset, the not-in-tmux segment is present.
Related
- Companion feature: the
where_am_i self-location primitive + self-first routing. That issue defines the tool; this one defines the semantics it must report. Ship together.
- Companion bug: caller socket advertised but never targeted — which is why
effective_socket_* must be a distinct field from caller_socket_* in the context payload.
- Upstream libtmux: bare
ValueError from set_environment; start_server() leaves a stale socket.
Environment
- libtmux-mcp: 0.1.0a17
- libtmux: 0.61.0
- fastmcp: 3.4.3 / mcp: 1.28.1
- tmux: 3.7b
- Python: 3.14.0
Cross-references — filed together from a single audit of v0.1.0a17. The self-location gap is the root cause; the rest are what it exposed.
(← this issue: #96)
Summary
libtmux-mcp can be invoked in four materially different situations, and it currently distinguishes none of them to the agent:
instructions— which can be silently dropped under the byte cap, and names a socket the tools don't actually queryAgent contextline is simply absent, with no replacement[], indistinguishable from "empty server"The only in-tmux detection happens once, at import, in
_build_instructions— and it feeds nothing but a sentence. Nothing else in the process consumes it.Separately:
create_sessionsilently spawns a long-lived tmux daemon (sourcing the user'stmux.conf) with no gate, no opt-in, and no signal in its response that it did so.Evidence
Detection exists but is inert
server.py#L195—_build_instructionsis the sole startup reader ofTMUX_PANEserver.py#L279— the lifespan probe checks only that the tmux binary exists; it never probes whether a server doesserver.py#L221— no negative branch: withTMUX/TMUX_PANEunset theAgent contextblock is simply omitted. Verified. Agents in scenarios (b)/(d) must infer their context from an absence.resources/hierarchy.py#L38— every registered resource is a hierarchy read. Nothing exposesinside_tmux, effective socket, or server-running state."No server" is indistinguishable from "empty server"
Verified empirically against a dead socket:
list_sessions/list_windows/list_panes→[];list_servers→[]. libtmux swallows the daemon-not-up error and returns an emptyQueryList:libtmux/server.py#L2372—Server.sessionsswallows everyLibTmuxExceptionlibtmux/server.py#L79—_fetch_or_empty(used by.panes/.windows) swallows only daemon-not-up and re-raises the restget_server_infois the only discriminator (is_alive=False) — and nothing in the instructions tells the agent to call it first:server_tools.py#L196Worse, on the default socket it reports
socket_name=Noneandsocket_path=Noneeven when alive — so it cannot even tell the agent which socket it just queried:server_tools.py#L210The error path is a dead-end loop
With no server running,
capture_pane(pane_id="%0")fails with "Pane not found: %0" and the suggestion "Calllist_panesto discover valid pane ids" — andlist_panesreturns[], because no daemon exists. The agent is sent in a circle._utils.py#L1013And "no tmux server" is logged as an unexpected ERROR for
set_environment, because libtmux raises a bareValueError(not aLibTmuxException), missing the expected-error branch and landing in the catch-all withlogger.exception:_utils.py#L1018libtmux/common.py#L115— the upstream bareValueErrorA routine "no server running" thereby pollutes operator error budgets as a non-bug.
Auto-start is silent, ungated, and unannounced
I probed every plausible candidate against a dead socket (
show_option,set_option,show_environment,set_environment,signal_channel,load_buffer,create_window) — none spawn a daemon.create_sessionis the only tool that does (is_aliveFalse → True):server_tools.py#L141libtmux/server.py#L2327—new_session→tmux new-session, which auto-starts the serverIt is registered with
ANNOTATIONS_CREATE(destructiveHint=False) in the plainmutatingtier — so a default-profile agent will spawn a long-lived tmux daemon, sourcing the user's~/.tmux.conf, as a routine "create":server_tools.py#L373SessionInfocarries no signal that a whole tmux server came into existence as a side effect — the agent and the audit log see an ordinary session create:models.py#L10And there is no "do not start a server" mode: no
start_servertool is registered,libtmux.Server.start_server()is never called anywhere in the package, and the only way to block an auto-start isLIBTMUX_SAFETY=readonly— which hides every mutating tool.server.py#L399Reproduction
Scenario (c) — no server, dead-end loop:
list_panes()→[]. 2.capture_pane(pane_id="%0")→ "Pane not found: %0 … Calllist_panesto discover valid pane ids." 3.list_panes()→[]. Observed: a loop with no way out and no statement that the server is simply not running. Expected: "No tmux server is running on socketnovel_socket."Scenario (c) — silent daemon spawn: on that same dead socket, call
create_session(session_name="x"). Observed: a tmux daemon is started (sourcing the user's config) andSessionInfolooks like any other create. Expected: either an explicit opt-in, or at minimumserver_started: truein the response.Suggested fix
1. Expose the context surface. This is the same tool proposed in the self-location issue —
where_am_i(readonly) + atmux://selfresource — whose payload deliberately covers server/safety state as well as caller identity:{ "inside_tmux": bool, "pane_id": str | None, "window_id": str | None, "session_id": str | None, "caller_socket_path": str | None, "effective_socket_name": str | None, # what the tools will ACTUALLY query "effective_socket_path": str | None, # (can differ from caller_* — see the socket bug) "server_running": bool, "live_server_count": int, "safety_level": str, # readonly | mutating | destructive "suppress_history": bool, }One call answers "where am I / is anything running / what will my tools target / what am I allowed to do". It replaces a prose suffix that vanishes silently under the 2048-byte instructions cap. The two issues should ship together — one tool, not two. (Naming rationale, including why
get_contextwas rejected — "context" is hopelessly overloaded in agent-land — lives in that issue.)Liveness must come from
Server.is_alive()— onelist-sessions, provably no daemon spawn (libtmux/server.py#L241). Do not infer liveness from socket-file existence:start-serverleaves a stale socket file behind after the sessionless daemon exits underexit-empty on(libtmux/server.py#L1448). And do not infer it from emptiness of.sessionsvs.panes— they swallow different exception sets.2. Make server auto-start opt-in. Add
allow_server_start: bool = Falsetocreate_session(default fromLIBTMUX_ALLOW_SERVER_START), and register a dedicatedstart_servertool wrapping libtmux's existingServer.start_server(). Starting a tmux daemon sources the user's config and leaves a long-lived background process — that should be an explicit, auditable action, not a side effect of a create taggeddestructiveHint=False.(Caveat for whoever implements this:
start_server()alone is insufficient — underexit-empty onthe sessionless daemon exits immediately andis_alive()still returnsFalse. A real "ensure server" must create a session.)3. Announce the spawn. Add
server_started: booltoSessionInfoso both the agent and theAuditMiddlewarerecord can see that acreate_sessionbrought a whole daemon into existence rather than joining an existing one.4. Give the not-found errors a real recovery hint. Have the targeted resolvers (
_resolve_pane/_resolve_window/_resolve_session) probeserver.is_alive()before raising, so the error names the actual cause:Also fix
show_environment, which returns{"variables": {}}on a dead server — indistinguishable from a genuinely empty environment.5. Map libtmux's bare
ValueErrorfromset_environment/unset_environmentinto the expected-error path in_map_exception_to_tool_error(or fix it upstream — see the libtmux issue), so "no tmux server running" stops being logged as an unexpected ERROR.6. State the negative case in the instructions. When
TMUX_PANEis absent, append an explicit segment rather than silently omitting the context block:Tests
where_am_i()outside tmux →inside_tmux=False, andserver_runningcorrectly reflects a live vs dead socket.where_am_i()on a dead socket →server_running=False,live_server_count=0; on a live one →True.create_session(allow_server_start=False)on a dead socket raises rather than spawning; assert no socket file appears.create_session(allow_server_start=True)on a dead socket returnsSessionInfo(server_started=True).capture_paneon a dead server produces an error naming "no tmux server", not "Pane not found → call list_panes".set_environmenton a dead server raises an expectedToolError, and is not logged vialogger.exception.TMUX_PANEunset, the not-in-tmux segment is present.Related
where_am_iself-location primitive + self-first routing. That issue defines the tool; this one defines the semantics it must report. Ship together.effective_socket_*must be a distinct field fromcaller_socket_*in the context payload.ValueErrorfromset_environment;start_server()leaves a stale socket.Environment
Cross-references — filed together from a single audit of
v0.1.0a17. The self-location gap is the root cause; the rest are what it exposed.filters={"is_caller": true}→[])where_am_iself-location primitive + self-first relational routing (root fix)tmux -L workqueriesdefault#94 — [bug] caller's socket advertised but never targeted;TMUXenv race vs. the threadpoollist_*unbounded, unscoped, unprojected, excluded from the response limiterTMUX_PANE); unreachablesocket_pathderivation(← this issue: #96)