Skip to content

Surface invocation context (in tmux? server running? which socket?) and make tmux-server auto-start explicit #96

Description

@tony

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#L221no 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>
  1. 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

  1. where_am_i() outside tmux → inside_tmux=False, and server_running correctly reflects a live vs dead socket.
  2. where_am_i() on a dead socket → server_running=False, live_server_count=0; on a live one → True.
  3. create_session(allow_server_start=False) on a dead socket raises rather than spawning; assert no socket file appears.
  4. create_session(allow_server_start=True) on a dead socket returns SessionInfo(server_started=True).
  5. capture_pane on a dead server produces an error naming "no tmux server", not "Pane not found → call list_panes".
  6. set_environment on a dead server raises an expected ToolError, and is not logged via logger.exception.
  7. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions