Every tool that resolves a pane_id or window_id raises an opaque Internal error against a grouped session or a linked window. Grouped sessions are ordinary usage — tmux new-session -t <existing> — and are what tmuxp creates.
pane_id is the identifier the server instructions explicitly tell agents to prefer.
Reproduction
s = Server(socket_name="probe")
a = s.new_session(session_name="alpha")
s.cmd("new-session", "-d", "-t", a.session_name, "-s", "grouped") # ordinary grouped session
s.panes.get(pane_id="%0", default=None)
list-panes -a rows for %0 -> [('%0', '$0'), ('%0', '$1')]
server.panes.get(pane_id=%0) -> RAISED MultipleObjectsReturned (msg='')
server.windows.get(window_id=@0) -> RAISED MultipleObjectsReturned (msg='')
isinstance LibTmuxException? False
Why it escapes everything
tmux list-panes -a / list-windows -a emit one row per holding session, so a grouped or linked window matches twice. QueryList.get() raises MultipleObjectsReturned on more than one match before it consults default, so default=None does not absorb it.
MultipleObjectsReturned subclasses bare Exception, not LibTmuxException. So it misses every arm of _map_exception_to_tool_error and lands in the catch-all, surfacing as ToolError("Unexpected error: MultipleObjectsReturned: ") — with an empty message, expected=False, logged at ERROR.
rg MultipleObjectsReturned across src/ and tests/: zero hits. Never caught, never tested.
Blast radius
Both central resolvers:
_resolve_pane — _utils.py:716, server.panes.get(pane_id=..., default=None)
_resolve_window — _utils.py:646, server.windows.get(window_id=..., default=None)
which means send_keys, capture_pane, get_pane_info, get_window_info, select_window, rename_window, move_window, split_window, wait_for_text, and others.
The self-kill guards are on the same path — session_tools.py:264 and window_tools.py:331 both do server.panes.get(pane_id=caller.pane_id, default=None) — so the guard raises instead of guarding.
list_windows also returns duplicate rows for a linked window.
Fix
Route the resolvers through libtmux's point lookups instead of filtering a server-wide listing:
Pane.from_pane_id(server=server, pane_id=pane_id)
Window.from_window_id(server=server, window_id=window_id)
These target the object with -t and let tmux's own cmd_find resolve it, so exactly one row comes back.
Sequencing matters. On the pinned libtmux>=0.61.0 those constructors still scan the whole server with -a and keep the last matching row — ordered by session name — so migrating today would trade a crash for a silently arbitrary session (the session you get would change if someone renamed a session). tmux-python/libtmux#713 rewrites them to ("-t", <id>). Do the migration after that lands and you get tmux's own answer; do it before and you inherit the arbitrary one.
So: this is not blocked on libtmux for the crash (you could catch MultipleObjectsReturned today), but the correct fix wants libtmux#713 released first.
Interim mitigation
If a fix is wanted before that release, MultipleObjectsReturned at minimum needs an arm in _map_exception_to_tool_error so the agent gets an actionable message rather than an empty Internal error.
Every tool that resolves a
pane_idorwindow_idraises an opaqueInternal erroragainst a grouped session or a linked window. Grouped sessions are ordinary usage —tmux new-session -t <existing>— and are what tmuxp creates.pane_idis the identifier the server instructions explicitly tell agents to prefer.Reproduction
Why it escapes everything
tmux list-panes -a/list-windows -aemit one row per holding session, so a grouped or linked window matches twice.QueryList.get()raisesMultipleObjectsReturnedon more than one match before it consultsdefault, sodefault=Nonedoes not absorb it.MultipleObjectsReturnedsubclasses bareException, notLibTmuxException. So it misses every arm of_map_exception_to_tool_errorand lands in the catch-all, surfacing asToolError("Unexpected error: MultipleObjectsReturned: ")— with an empty message,expected=False, logged at ERROR.rg MultipleObjectsReturnedacrosssrc/andtests/: zero hits. Never caught, never tested.Blast radius
Both central resolvers:
_resolve_pane—_utils.py:716,server.panes.get(pane_id=..., default=None)_resolve_window—_utils.py:646,server.windows.get(window_id=..., default=None)which means
send_keys,capture_pane,get_pane_info,get_window_info,select_window,rename_window,move_window,split_window,wait_for_text, and others.The self-kill guards are on the same path —
session_tools.py:264andwindow_tools.py:331both doserver.panes.get(pane_id=caller.pane_id, default=None)— so the guard raises instead of guarding.list_windowsalso returns duplicate rows for a linked window.Fix
Route the resolvers through libtmux's point lookups instead of filtering a server-wide listing:
These target the object with
-tand let tmux's owncmd_findresolve it, so exactly one row comes back.Sequencing matters. On the pinned
libtmux>=0.61.0those constructors still scan the whole server with-aand keep the last matching row — ordered by session name — so migrating today would trade a crash for a silently arbitrary session (the session you get would change if someone renamed a session). tmux-python/libtmux#713 rewrites them to("-t", <id>). Do the migration after that lands and you get tmux's own answer; do it before and you inherit the arbitrary one.So: this is not blocked on libtmux for the crash (you could catch
MultipleObjectsReturnedtoday), but the correct fix wants libtmux#713 released first.Interim mitigation
If a fix is wanted before that release,
MultipleObjectsReturnedat minimum needs an arm in_map_exception_to_tool_errorso the agent gets an actionable message rather than an emptyInternal error.