Skip to content

Commit 0de4332

Browse files
committed
mcp(feat[wait_for_pane_exit]): Report how a pane's process ended
why: A job started with split_window or respawn_pane shell= has no prompt to run_command against and no marker to wait_for_text for. libtmux's Pane.wait returns its exit status and signal. what: - Add wait_for_pane_exit and PaneExitResult; a process still running at the timeout is a timed_out result - Wait in 0.5 s Pane.wait slices so a cancelled call leaves a polling thread for at most one slice, hold remain-on-exit across slices, and restore it only after the last slice has ended - Document it, list it in the index, README and tool expectations - Tests: status of an already-dead pane and across slices, timeout restores the option, cancel returns promptly; the cancel test fails without the wait for the in-flight slice
1 parent 011d2bf commit 0de4332

11 files changed

Lines changed: 363 additions & 2 deletions

File tree

‎CHANGES‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@
66
_Notes on upcoming releases will be added here_
77
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
88

9+
### What's new
10+
11+
**`wait_for_pane_exit` reports how a pane's process ended**
12+
13+
`wait_for_pane_exit` blocks until the process tmux started in a pane exits and
14+
returns its exit status or terminating signal, so a job started with
15+
`split_window(shell=...)` or `respawn_pane(shell=...)` needs no prompt or output
16+
marker to be read. A process still running at the timeout comes back with
17+
`timed_out=true`. The pane stays on screen, dead, so `capture_pane` can read its
18+
output.
19+
920
### Fixes
1021

1122
**`capture_since` reports a flood that scrolls the cursor out of a full history**

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ commands, read output, orchestrate panes.
3535
| **Batch** | `call_read_tools_batch` |
3636
| **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` |
3737
| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
38-
| **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_channel`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
38+
| **Pane** | `run_command`, `send_keys`, `send_keys_batch`, `paste_text`, `capture_pane`, `capture_since`, `snapshot_pane`, `search_panes`, `find_pane_by_position`, `get_pane_info`, `wait_for_text`, `wait_for_channel`, `wait_for_pane_exit`, `signal_channel`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `respawn_pane`, `kill_pane` |
3939
| **Options** | `show_option`, `set_option` |
4040
| **Environment** | `show_environment`, `set_environment` |
4141
| **Buffers** | `load_buffer`, `paste_buffer`, `show_buffer`, `delete_buffer` |

‎docs/tools/index.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ leave socket selection inside each nested tool's arguments. See
2828
- {tool}`run-command` — one call to run a shell command, wait for completion, capture output, and return exit status
2929
- {tool}`send-keys` / {tool}`send-keys-batch` — raw interactive input for TUIs, control keys, and persistent shell state
3030
- {tool}`wait-for-channel` — low-level custom completion when `run-command` does not fit the shell composition
31+
- {tool}`wait-for-pane-exit` — exit status of a one-shot job started with `split-window` or `respawn-pane` `shell=`
3132
- For output the agent does not author (third-party logs, daemon prompts), use {tool}`wait-for-text` or {tool}`capture-since`
3233
- Pasting multi-line text? → {tool}`paste-text`
3334

@@ -311,6 +312,12 @@ Stage multi-line text into an MCP-namespaced tmux buffer.
311312
Block until a tmux ``wait-for`` channel is signalled.
312313
:::
313314

315+
:::{grid-item-card} wait_for_pane_exit
316+
:link: wait-for-pane-exit
317+
:link-type: ref
318+
Block until a pane's process exits and report its exit status.
319+
:::
320+
314321
:::{grid-item-card} signal_channel
315322
:link: signal-channel
316323
:link-type: ref

‎docs/tools/pane/index.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ Block until new output matches (or any new output appears).
8989
Block until a tmux wait-for channel is signalled.
9090
:::
9191

92+
:::{grid-item-card} {tooliconl}`wait-for-pane-exit`
93+
Block until a pane's process exits and report its exit status.
94+
:::
95+
9296
:::{grid-item-card} {tooliconl}`signal-channel`
9397
Signal a waiting channel.
9498
:::
@@ -128,6 +132,7 @@ enter-copy-mode
128132
exit-copy-mode
129133
wait-for-text
130134
wait-for-channel
135+
wait-for-pane-exit
131136
signal-channel
132137
respawn-pane
133138
kill-pane
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Wait for pane exit
2+
3+
Blocks until the process tmux started in a pane ends, then reports its exit
4+
status or terminating signal. It waits for the pane's own process, the one
5+
given to {tooliconl}`split-window` or {tooliconl}`respawn-pane` as `shell`, not
6+
for a command typed into an interactive shell. For a typed command use
7+
{tooliconl}`run-command`.
8+
9+
```python
10+
pane = split_window(shell="make test")
11+
wait_for_pane_exit(pane_id=pane.pane_id, timeout=120)
12+
# PaneExitResult(exited=True, exit_status=2, ...)
13+
```
14+
15+
The pane stays on screen as a dead pane after the wait, so
16+
{tooliconl}`capture-pane` can read its output; remove it with
17+
{tooliconl}`kill-pane`. A pane whose process ended before the call has already
18+
closed and cannot be waited on, so a very short job needs `remain-on-exit`
19+
set before it starts.
20+
21+
```{fastmcp-tool} pane_tools.wait_for_pane_exit
22+
```
23+
24+
**Use when** you started a one-shot job in its own pane and need to know
25+
whether it succeeded, without a prompt or a marker to match.
26+
27+
**Avoid when** the command runs in an interactive shell. Use
28+
{tooliconl}`run-command`, or {tooliconl}`wait-for-channel` for custom
29+
completion.
30+
31+
**Side effects:** Sets the pane's `remain-on-exit` option for the duration of
32+
the wait and restores it. Blocks up to `timeout` seconds; a pane still running
33+
at expiry returns `timed_out=true`.
34+
35+
```{fastmcp-tool-input} pane_tools.wait_for_pane_exit
36+
```

‎src/libtmux_mcp/models.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,32 @@ class CaptureSinceResult(BaseModel):
357357
)
358358

359359

360+
class PaneExitResult(BaseModel):
361+
"""How the process tmux started in a pane ended, or that it has not."""
362+
363+
pane_id: str = Field(description="Pane ID that was waited on")
364+
exited: bool = Field(description="True when the pane's process ended")
365+
exit_status: int | None = Field(
366+
default=None,
367+
description=(
368+
"Exit status of the pane's process. None when it is still running, "
369+
"or when a signal ended it (see ``signal``)"
370+
),
371+
)
372+
signal: int | None = Field(
373+
default=None,
374+
description=(
375+
"Signal number that ended the process. None when it exited "
376+
"normally, is still running, or tmux 3.2a, which does not report it"
377+
),
378+
)
379+
timed_out: bool = Field(description="True when the wait expired first")
380+
elapsed_seconds: float = Field(description="Time spent waiting in seconds")
381+
effective_timeout: float = Field(
382+
description="Timeout enforced after the server wait ceiling, in seconds"
383+
)
384+
385+
360386
class RunCommandResult(BaseModel):
361387
"""Result of running a shell command in a pane."""
362388

‎src/libtmux_mcp/tools/pane_tools/__init__.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
kill_pane,
4242
respawn_pane,
4343
set_pane_title,
44+
wait_for_pane_exit,
4445
)
4546
from libtmux_mcp.tools.pane_tools.meta import display_message, snapshot_pane
4647
from libtmux_mcp.tools.pane_tools.pipe import pipe_pane
@@ -73,6 +74,7 @@
7374
"set_pane_title",
7475
"snapshot_pane",
7576
"swap_pane",
77+
"wait_for_pane_exit",
7678
"wait_for_text",
7779
]
7880

@@ -123,6 +125,13 @@ def register(mcp: FastMCP) -> None:
123125
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
124126
tags={TOOLSET_EXECUTE},
125127
)(respawn_pane)
128+
# Like the other waits, bounded by the server wait ceiling, so it stays
129+
# out of the batch wrappers.
130+
mcp.tool(
131+
title="Wait For tmux Pane Exit",
132+
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,
133+
tags={TOOLSET_MANAGE, TAG_SELF_BOUNDED},
134+
)(wait_for_pane_exit)
126135
mcp.tool(
127136
title="Set Pane Title",
128137
annotations=ANNOTATIONS_AMBIENT_UNKNOWN,

‎src/libtmux_mcp/tools/pane_tools/lifecycle.py‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
from __future__ import annotations
44

5+
import asyncio
6+
import threading
7+
import time
58
import typing as t
69

10+
from libtmux import exc
11+
712
from libtmux_mcp._history import _prepare_spawn_environment
813
from libtmux_mcp._utils import (
914
ExpectedToolError,
@@ -16,11 +21,22 @@
1621
_resolve_window,
1722
_serialize_pane,
1823
handle_tool_errors,
24+
handle_tool_errors_async,
1925
)
26+
from libtmux_mcp._wait_policy import _wait_ceiling_seconds
2027
from libtmux_mcp.models import (
28+
PaneExitResult,
2129
PaneInfo,
2230
)
2331

32+
if t.TYPE_CHECKING:
33+
from libtmux.pane import Pane
34+
35+
#: Longest single ``Pane.wait`` call. ``Pane.wait`` polls in a worker thread
36+
#: that cannot be interrupted, so the tool waits in slices this long and a
37+
#: cancelled call leaves a thread running for at most one of them.
38+
_EXIT_WAIT_SLICE_SECONDS = 0.5
39+
2440
#: The four window corners ``find_pane_by_position`` accepts.
2541
PaneCorner = t.Literal["top-left", "top-right", "bottom-left", "bottom-right"]
2642

@@ -347,3 +363,135 @@ def _innermost_score(p: t.Any) -> int:
347363

348364
matches.sort(key=_innermost_score, reverse=True)
349365
return _serialize_pane(matches[0])
366+
367+
368+
def _pane_option_state(pane: Pane) -> list[str]:
369+
"""Return the pane's own ``remain-on-exit`` value, empty when unset."""
370+
return list(pane.cmd("show-options", "-pqv", "remain-on-exit").stdout)
371+
372+
373+
def _restore_remain_on_exit(pane: Pane, previous: list[str]) -> None:
374+
"""Put a pane's ``remain-on-exit`` back as :func:`_pane_option_state` saw it."""
375+
if previous:
376+
pane.cmd("set-option", "-p", "remain-on-exit", previous[0])
377+
else:
378+
pane.cmd("set-option", "-p", "-u", "remain-on-exit")
379+
380+
381+
def _wait_slice(pane: Pane, seconds: float, finished: threading.Event) -> t.Any:
382+
"""Run one ``Pane.wait`` slice and flag its end, for the caller's cleanup.
383+
384+
``Pane.wait`` restores ``remain-on-exit`` as it found it when it
385+
returns; a cancelled caller must let that finish before restoring the
386+
option itself, or the slice's restore lands last and wins.
387+
"""
388+
finished.clear()
389+
try:
390+
return pane.wait(timeout=seconds)
391+
finally:
392+
finished.set()
393+
394+
395+
@handle_tool_errors_async
396+
async def wait_for_pane_exit(
397+
pane_id: str | None = None,
398+
session_name: str | None = None,
399+
session_id: str | None = None,
400+
window_id: str | None = None,
401+
timeout: float = 30.0,
402+
socket_name: str | None = None,
403+
) -> PaneExitResult:
404+
"""Wait for the process tmux started in a pane to exit; report how it ended.
405+
406+
Use after ``split_window(shell=...)`` or ``respawn_pane(shell=...)`` to
407+
learn a one-shot job's exit status or terminating signal without a
408+
prompt or a marker. It waits for the pane's own process, not for a
409+
command typed into a shell: for that use ``run_command``.
410+
411+
The pane stays on screen as a dead pane afterwards so its output can be
412+
read with ``capture_pane``; remove it with ``kill_pane``. A pane that
413+
closed before this call is gone and cannot be waited on, so start
414+
short-lived jobs in a pane created with ``remain-on-exit`` already on.
415+
416+
Parameters
417+
----------
418+
pane_id : str, optional
419+
Pane ID (e.g. '%1').
420+
session_name : str, optional
421+
Session name for pane resolution.
422+
session_id : str, optional
423+
Session ID (e.g. '$1') for pane resolution.
424+
window_id : str, optional
425+
Window ID for pane resolution.
426+
timeout : float
427+
Maximum seconds to wait. Capped by the same server wait ceiling as
428+
``wait_for_text``; the value enforced is reported as
429+
``effective_timeout``. A pane still running at expiry is a result
430+
with ``timed_out=true``, not an error.
431+
socket_name : str, optional
432+
tmux socket name.
433+
434+
Returns
435+
-------
436+
PaneExitResult
437+
Exit status and signal, or ``timed_out=true`` when the process is
438+
still running.
439+
"""
440+
if timeout <= 0:
441+
msg = "timeout must be positive"
442+
raise ExpectedToolError(msg)
443+
effective_timeout = min(timeout, _wait_ceiling_seconds())
444+
445+
server = _get_server(socket_name=socket_name)
446+
pane = _resolve_pane(
447+
server,
448+
pane_id=pane_id,
449+
session_name=session_name,
450+
session_id=session_id,
451+
window_id=window_id,
452+
)
453+
target_pane_id = pane.pane_id
454+
if target_pane_id is None:
455+
msg = "resolved pane has no pane_id"
456+
raise ExpectedToolError(msg)
457+
458+
started = time.monotonic()
459+
deadline = started + effective_timeout
460+
461+
# Hold remain-on-exit across the slices: ``Pane.wait`` restores the
462+
# option on every return, and a process that exits between two slices
463+
# would otherwise close the pane and take its exit status with it.
464+
finished = threading.Event()
465+
finished.set()
466+
previous = await asyncio.to_thread(_pane_option_state, pane)
467+
await asyncio.to_thread(pane.cmd, "set-option", "-p", "remain-on-exit", "on")
468+
try:
469+
while True:
470+
remaining = deadline - time.monotonic()
471+
slice_seconds = max(min(_EXIT_WAIT_SLICE_SECONDS, remaining), 0.01)
472+
try:
473+
result = await asyncio.to_thread(
474+
_wait_slice, pane, slice_seconds, finished
475+
)
476+
except exc.WaitTimeout:
477+
if time.monotonic() >= deadline:
478+
return PaneExitResult(
479+
pane_id=target_pane_id,
480+
exited=False,
481+
timed_out=True,
482+
elapsed_seconds=round(time.monotonic() - started, 3),
483+
effective_timeout=effective_timeout,
484+
)
485+
continue
486+
return PaneExitResult(
487+
pane_id=target_pane_id,
488+
exited=True,
489+
exit_status=result.status,
490+
signal=result.signal,
491+
timed_out=False,
492+
elapsed_seconds=round(time.monotonic() - started, 3),
493+
effective_timeout=effective_timeout,
494+
)
495+
finally:
496+
await asyncio.to_thread(finished.wait, _EXIT_WAIT_SLICE_SECONDS + 1.0)
497+
await asyncio.to_thread(_restore_remain_on_exit, pane, previous)

0 commit comments

Comments
 (0)