tmux-python / tmux-python/libtmux-mcp
Add a self-location primitive (where_am_i): agents must dump every pane on the server to learn which pane they are in
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 13
- Forks
- 0
- Avg merge
- 13h 52m
- Merged PRs (30d)
- 4
Description
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/alwaysLoad — window_tools.py#L503 — while no self primitive exists at all.
Evidence
The cheap primitive exists and is never exposed:
_utils.py#L96—CallerIdentity, a frozen dataclass:socket_path,server_pid,session_id,pane_id_utils.py#L115—_get_caller_identity();#L123is a bareos.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:
window_tools.py#L100— unscopedlist_panes→panes = server.panes(all sessions, all windows)models.py#L53—PaneInfo= 21 fields/row
"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:
_utils.py#L738—_resolve_pane()with no target
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:
- No self primitive exists, so the agent follows the instructions and calls
list_panes()unscoped. - It receives every pane on the socket — on a busy dev box, ~11K tokens, mostly panes in unrelated sessions.
- It hand-computes geometry from
pane_top/pane_bottomto derive "below", with zero guidance. - It then discovers
kill_paneis hidden at the defaultmutatingsafety tier — a fact the instructions never state — and falls back tosend_keys "exit"or shellingtmux kill-panethroughrun_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 towhich/wc/whereis. Because this server shipsrun_commandandsend_keys, a tool namedwhoamicompetes 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 taxonomywhoami≡id -un; this tool answers thettyquestion. Kubernetes hit the same fork and refused barewhoami, shippingkubectl 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):
libtmux/server.py#L172—Server(socket_path=...)(-S)libtmux/neo.py#L389—Paneis a dataclass;serveris the only required fieldlibtmux/pane.py#L214—Pane.cmdauto-injects-t <pane_id>libtmux/pane.py#L880—Pane.display_message(..., get_text=True)libtmux/server.py#L241—Server.is_alive()forserver_running(onelist-sessions, provably no daemon spawn)
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 Obj — libtmux/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 thatwindow_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_MAP — window_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
- Inside a pane: returns
inside_tmux=Trueand pane/window/session ids matching$TMUX_PANE/$TMUX. - Outside tmux: returns
inside_tmux=Falseand does not raise. - Issues at most one tmux subprocess (assert via a
Server.cmdspy) — guards against regressing to thefrom_pane_id/.panes.filter()enumeration path. - On a non-
defaultcaller socket, returns that socket and does not silently resolve againstdefault(see the socket-coherence issue). server_runningisFalseon a dead socket andTrueon a live one.- Instructions assertion:
_BASE_INSTRUCTIONScontains 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-readsos.environper call and races with libtmux'sTMUXdeletion. - 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.
- #19 —
is_callerfalse positives across sockets. - Upstream: libtmux has no self-location API (
TMUX_PANEappears 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.
- tmux-python/libtmux-mcp#92 — [bug] documented self-location recipe is unexecutable (
filters={"is_caller": true}→[]) - tmux-python/libtmux-mcp#93 — [feat]
where_am_iself-location primitive + self-first relational routing (root fix) - tmux-python/libtmux-mcp#94 — [bug] caller's socket advertised but never targeted;
TMUXenv race vs. the threadpool - tmux-python/libtmux-mcp#95 — [perf]
list_*unbounded, unscoped, unprojected, excluded from the response limiter - tmux-python/libtmux-mcp#96 — [feat] invocation context (in tmux? server running?) + explicit tmux-server auto-start
- tmux-python/libtmux#704 — upstream: no self-location API (
TMUX_PANE); unreachablesocket_pathderivation
(← this issue: #93)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with _utils.py's CallerIdentity and _get_caller_identity(), then inspect tool registration and guidance in server.py and window_tools.py, including list_panes and DISCOVERY_META. Done means a readonly where_am_i tool exposes the caller context described in the issue, avoids full pane enumeration, and updates routing guidance for relational prompts.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, cli, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100