You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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(); #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)."
"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:
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_bottom to derive "below", with zero guidance.
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.
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 whoami ≡ id -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:
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.
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).
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_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=True and pane/window/session ids matching $TMUX_PANE / $TMUX.
Outside tmux: returns inside_tmux=False and does not raise.
Issues at most one tmux subprocess (assert via a Server.cmd spy) — guards against regressing to the from_pane_id / .panes.filter() enumeration path.
On a non-default caller socket, returns that socket and does not silently resolve against default (see the socket-coherence issue).
server_running is False on a dead socket and True on a live one.
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.
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 foris_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 pureos.environread ofTMUX_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):PaneInfois 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:
list_*family —server.py#L92list_panes" when unsure —server.py#L183list_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_panesis pinned always-visible viaanthropic/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_calleris 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 targetAn agent calling
get_pane_info()orsnapshot_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_positiononly 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:
list_panes()unscoped.pane_top/pane_bottomto derive "below", with zero guidance.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_identityis lexically consistent with this repo's own vocabulary (CallerIdentity,_get_caller_identity(),is_caller) and inherits AWS STS'sGetCallerIdentityprior. But it lands inside the existingget_pane_info/get_window_info/get_session_info/get_server_infofamily. 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 grabbingget_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
CallerIdentityas the return model so theis_callervocabulary loop stays coherent, and put the high-recall tokens in the description, where they can only attract and never mis-route:That last line converts the strongest failure mode into an explicit anti-trigger. Do not ship
whoamias an alias — it would re-open the routing fork againstrun_commandand add a 58th tool.Prior art worth copying: the GitHub MCP Server solves this exact problem with
get_me(explicitly notwhoami) 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:
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_levelearns its place: an agent asked to "close the panes below" currently burns an unboundedlist_panesand only then discoverskill_paneis 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 -awith the wide-Ftemplate, then filter in Python):Pane.from_pane_id()—neo.py#L787server.panes.filter(pane_id=…)—query_list.py#L513Window.active_panePane(...)+display_messageBonus:
socket_pathandpidare universal format tokens onObj—libtmux/neo.py#L146— so the same singledisplay-messagereturns the server's socket path and PID, the first two components ofTMUX=<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-lifetimeOnceLock)._get_caller_identity()currently re-readsos.environper call, which is racy — see the companion socket/race issue.2.
tmux://selfresourceRegister the same payload as a
FunctionResource. Resources cost nothing intools/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:
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_ifor self; neverlist_panesto 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_GAPinto tool descriptions) frees ~250 bytes. Note the 2048 cap is a project convention, not a protocol limit — nothing in fastmcp or the MCP SDK boundsinstructions(fastmcp/server/low_level.py#L178).4. Make "self" addressable as input
Accept
pane_id="self"(orcaller: bool = False) onlist_panes/get_pane_info/snapshot_pane/capture_pane, resolved via_get_caller_identity(). This also closes the_utils.py#L738trap where an untargeted resolve returns the first pane.5. (Optional) A real relational primitive
Extend
find_pane_by_position, or addfind_adjacent_panes(direction=above|below|left|right, reference="self"). The direction vocabulary already exists as_DIRECTION_MAP—window_tools.py#L36(used bysplit_window) — so reuse it. Today an agent must hand-compute geometry frompane_top/pane_bottomwith no guidance.Tests
inside_tmux=Trueand pane/window/session ids matching$TMUX_PANE/$TMUX.inside_tmux=Falseand does not raise.Server.cmdspy) — guards against regressing to thefrom_pane_id/.panes.filter()enumeration path.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._BASE_INSTRUCTIONScontains the self-first rule and no longer contains"filter list_panes".Related
filters={"is_caller": true}silently returns[]— the currently-documented recipe does not even execute._get_caller_identity()re-readsos.environper call and races with libtmux'sTMUXdeletion.list_*family is unbounded, unscoped, and excluded from the response limiter.is_callerfalse positives across sockets.TMUX_PANEappears nowhere in the package).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: #93)