Skip to content

Add a self-location primitive (where_am_i): agents must dump every pane on the server to learn which pane they are in #93

Description

@tony

Summary

There is no way for an agent to ask "where am I?". The only sanctioned answer is to call list_panes — which, unscoped, serializes every pane on the entire tmux server — and scan the rows for is_caller: true. The server instructions say so, and even name the gap: "(no whoami tool)".

This is self-inflicted, because the answer is already computed, for free, in-process. _get_caller_identity() is a pure os.environ read of TMUX_PANE + TMUX (socket_path,server_pid,session_id). It costs zero tmux round-trips, and already runs on every destructive-action guard and once per pane during serialization. It is never exposed.

The most common question an agent asks is answered by the most expensive tool the server offers.

Measured cost of the current path (list_panes, no args):

panes on server wire bytes ≈ tokens
10 9,193 ~2.5K
50 46,073 ~12.8K
100 92,173 ~25.6K

PaneInfo is 21 fields/row, and fastmcp emits every structured result twice (content + structuredContent) — fastmcp/tools/base.py#L365 — so ≈240 tokens/pane.

Measured cost of the answer that already exists: one targeted tmux display-message = 3.3 ms, one subprocess, one row.

The real failure is routing, not cost

Prompts like "close the panes below", "what's in the pane above", "the other window", "my session" are relational — expressed relative to the agent's own position. Answering them correctly requires resolving self first, then scoping. "Below" is undefined without a reference frame.

Nothing in the shipped guidance says this. Three places push the opposite way:

  • the metadata-vs-content rule routes all metadata questions to the list_* family — server.py#L92
  • the readonly hint tells the agent to "probe list_panes" when unsure — server.py#L183
  • list_panes' own description claims to cover "'this pane', 'current pane', 'the bottom shell'"window_tools.py#L55 — i.e. it advertises itself as the answer to precisely the prompts that should trigger self-resolution.

And list_panes is pinned always-visible via anthropic/alwaysLoadwindow_tools.py#L503 — while no self primitive exists at all.

Evidence

The cheap primitive exists and is never exposed:

  • _utils.py#L96CallerIdentity, a frozen dataclass: socket_path, server_pid, session_id, pane_id
  • _utils.py#L115_get_caller_identity(); #L123 is a bare os.environ.get("TMUX_PANE") / os.environ.get("TMUX")

The gap, stated by the server itself:

  • server.py#L208"...filter list_panes for it to answer 'which pane am I in?' (no whoami tool)."

The expensive path it points at:

"Self" is not addressable as tool input. is_caller is output-only, and an untargeted resolve does not mean "me" — it silently means the first pane of the first window of the first session:

An agent calling get_pane_info() or snapshot_pane() with no args believes it is inspecting itself, and is not. This is the silent-wrong-answer trap that most constrains the naming decision below.

No relational primitive. find_pane_by_position only handles the four corners, is never referenced in the instructions, and carries no discovery meta — lifecycle.py#L274.

Reproduction

Ask any MCP-connected agent, from inside a tmux pane: "close the panes below."

Observed, end to end:

  1. No self primitive exists, so the agent follows the instructions and calls list_panes() unscoped.
  2. It receives every pane on the socket — on a busy dev box, ~11K tokens, mostly panes in unrelated sessions.
  3. It hand-computes geometry from pane_top/pane_bottom to derive "below", with zero guidance.
  4. It then discovers kill_pane is hidden at the default mutating safety tier — a fact the instructions never state — and falls back to send_keys "exit" or shelling tmux kill-pane through run_command.

The correct trace: where_am_i (1 row, ~3 ms) → list_panes(window_id=<mine>) (3 rows) → act.

Naming (please bikeshed here, not in review)

I surveyed 41 agent codebases and the MCP/API ecosystem before proposing a name. Summary of what the evidence rules out:

  • whoami — reject. 305 occurrences across the corpus; zero are LLM-facing tool names. It is hard-coded into the read-only safe-shell allowlist of four agents (codex, crush, gemini-cli, qwen-code), next to which/wc/whereis. Because this server ships run_command and send_keys, a tool named whoami competes against a shell path the model has seen far more often, that is pre-approved (no confirmation prompt), and that returns a plausible, confidently wrong answer — the OS username. In Unix taxonomy whoamiid -un; this tool answers the tty question. Kubernetes hit the same fork and refused bare whoami, shipping kubectl auth whoami.
  • get_context / tmux_context — reject. "Context" is the most overloaded token in agent-land (contextWindow, tool_context, context_length, ToolContext, …). An agent asked "how much context is left?" could plausibly fire it.
  • me / self / get_self — reject. Stopwords; near-invisible to keyword/BM25 tool search, and too generic to be a unique retrieval key across a 57-tool server.

That leaves two real candidates, and the tiebreaker is a correctness argument, not taste:

get_caller_identity is lexically consistent with this repo's own vocabulary (CallerIdentity, _get_caller_identity(), is_caller) and inherits AWS STS's GetCallerIdentity prior. But it lands inside the existing get_pane_info / get_window_info / get_session_info / get_server_info family. Similar tool names are the best-documented tool-selection failure mode, and at 57 tools this server is past the usual accuracy cliff. The near-miss it invites — the model grabbing get_pane_info — is exactly the silent-wrong-answer trap documented above (_resolve_pane() with no target returns the first pane, not you).

Proposed: where_am_i — lexically distant from all 56 incumbents, and the only candidate whose head token is spatial, matching the relational prompts (below/above/this/my/current) it must fire on. Name and user phrasing share surface form, which is the cheapest retrieval path.

Keep CallerIdentity as the return model so the is_caller vocabulary loop stays coherent, and put the high-recall tokens in the description, where they can only attract and never mis-route:

title: "Where am I? — current tmux pane, window, session, server"
description:
  Resolve the caller's own tmux location in ONE cheap call: which pane, window,
  session and socket this MCP server runs inside; whether a tmux server is running
  at all; and the current safety tier. Call this FIRST for any relational prompt —
  "this/my/current pane", "the pane below/above me", "the other pane" — then scope
  the follow-up to the returned window_id. Replaces dumping every pane via
  list_panes and grepping for is_caller.
  NOT the shell `whoami`: this does NOT return your OS username.

That last line converts the strongest failure mode into an explicit anti-trigger. Do not ship whoami as an alias — it would re-open the routing fork against run_command and add a 58th tool.

Prior art worth copying: the GitHub MCP Server solves this exact problem with get_me (explicitly not whoami) plus a server instruction — "Always call 'get_me' first to understand current user permissions and context." That is the primitive and the routing rule, already proven in production.

Suggested fix

1. The tool (readonly, DISCOVERY_META)

One tool, one call. The libtmux chain below is one tmux subprocess (~3.3 ms) and zero enumeration:

def where_am_i() -> CallerContext:
    caller = _get_caller_identity()                  # pure os.environ; no tmux call
    if caller is None:
        return CallerContext(inside_tmux=False, server_running=..., safety_level=...)
    srv = Server(socket_path=caller.socket_path)     # -S <path>; sidesteps libtmux's
                                                     # unreachable socket_path derivation
    pane = Pane(server=srv, pane_id=caller.pane_id)  # dataclass ctor, zero tmux calls
    row = pane.display_message(                      # Pane.cmd auto-injects -t <pane_id>
        "#{session_id}\x1e#{window_id}\x1e#{pane_id}"
        "\x1e#{socket_path}\x1e#{pid}\x1e#{session_name}\x1e#{window_index}",
        get_text=True,
    )
    ...

Payload (one flat object — this subsumes the "invocation context" ask; see Related):

{
  "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,   # (these can differ from caller_* — see the socket bug)
  "server_running": bool,
  "safety_level": str,                   # readonly | mutating | destructive
  "suppress_history": bool,
}

safety_level earns its place: an agent asked to "close the panes below" currently burns an unbounded list_panes and only then discovers kill_pane is hidden at the default tier.

Supporting libtmux APIs (v0.61.0):

Anti-pattern — do not build this on (all run a full list-panes -a with the wide -F template, then filter in Python):

approach measured
Pane.from_pane_id()neo.py#L787 28.4 ms
server.panes.filter(pane_id=…)query_list.py#L513 28.7 ms
Window.active_pane semantically wrong ("focused" ≠ "me")
Pane(...) + display_message 3.3 ms

Bonus: socket_path and pid are universal format tokens on Objlibtmux/neo.py#L146 — so the same single display-message returns the server's socket path and PID, the first two components of TMUX=<socketpath>,<pid>,<session>. The tool can therefore prove the pane is the caller's in the same round-trip, collapsing _compute_is_caller's socket cross-check and _effective_socket_path's three-step fallback.

Snapshot the identity at startup. Six agents in the surveyed corpus detect tmux (codex, qwen-code, kimi-code, gptme, oh-my-pi, crush), all via TMUX/TMUX_PANE, and all snapshot once (codex caches it in a process-lifetime OnceLock). _get_caller_identity() currently re-reads os.environ per call, which is racy — see the companion socket/race issue.

2. tmux://self resource

Register the same payload as a FunctionResource. Resources cost nothing in tools/list, are read on demand, and are recomputed per read — unlike the current agent-context paragraph, which is frozen at import and, under the self-imposed 2048-byte cap, silently degrades through three shorter variants and can be dropped entirely (server.py#L216).

fastmcp already supports this — server.py#L1862 (@mcp.resource), resources/types.py#L21 (TextResource) — and six hierarchy resources already ship (hierarchy.py#L37). The plumbing works; none of them expose self.

3. The self-first routing rule

This is what actually prevents the failure. Add an instruction segment:

RELATIONAL PROMPTS ("below/above/left/right/other/this/here/my/current") → resolve SELF first (where_am_i), then scope to that window_id/session_id. Never list the whole server to answer a relational prompt. If not inside tmux, there is no "self" — ask the user; do not guess.

And delete the bad steer: replace "filter list_panes for it to answer 'which pane am I in?' (no whoami tool)" with "You are pane %N. Call where_am_i for self; never list_panes to find yourself." — correct and shorter, buying back budget.

Budget note: instructions are capped at 2048 bytes (server.py#L137) and measure 2024 bytes at the readonly tier — 24 bytes of headroom. New guidance must be paid for by trimming; the module's own advice (push _INSTR_HOOKS_GAP / _INSTR_BUFFERS_GAP into tool descriptions) frees ~250 bytes. Note the 2048 cap is a project convention, not a protocol limit — nothing in fastmcp or the MCP SDK bounds instructions (fastmcp/server/low_level.py#L178).

4. Make "self" addressable as input

Accept pane_id="self" (or caller: bool = False) on list_panes / get_pane_info / snapshot_pane / capture_pane, resolved via _get_caller_identity(). This also closes the _utils.py#L738 trap where an untargeted resolve returns the first pane.

5. (Optional) A real relational primitive

Extend find_pane_by_position, or add find_adjacent_panes(direction=above|below|left|right, reference="self"). The direction vocabulary already exists as _DIRECTION_MAPwindow_tools.py#L36 (used by split_window) — so reuse it. Today an agent must hand-compute geometry from pane_top/pane_bottom with no guidance.

Tests

  1. Inside a pane: returns inside_tmux=True and pane/window/session ids matching $TMUX_PANE / $TMUX.
  2. Outside tmux: returns inside_tmux=False and does not raise.
  3. Issues at most one tmux subprocess (assert via a Server.cmd spy) — guards against regressing to the from_pane_id / .panes.filter() enumeration path.
  4. On a non-default caller socket, returns that socket and does not silently resolve against default (see the socket-coherence issue).
  5. server_running is False on a dead socket and True on a live one.
  6. Instructions assertion: _BASE_INSTRUCTIONS contains the self-first rule and no longer contains "filter list_panes".

Related

  • Companion bug: filters={"is_caller": true} silently returns [] — the currently-documented recipe does not even execute.
  • Companion bug: the caller's socket is advertised but never targeted; _get_caller_identity() re-reads os.environ per call and races with libtmux's TMUX deletion.
  • Companion perf issue: the list_* family is unbounded, unscoped, and excluded from the response limiter.
  • Companion issue: invocation-context semantics (explicit server auto-start, not-in-tmux branch, dead-end error paths) — that issue's context surface is this tool; they should ship together.
  • is_caller annotation gives false positives across tmux sockets #19is_caller false positives across sockets.
  • Upstream: libtmux has no self-location API (TMUX_PANE appears nowhere in the package).

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: #93)

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