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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions