Skip to content

Commit d5362a9

Browse files
committed
mcp(feat[pane_tools]): add respawn_pane for in-place shell recovery
why: when an agent wedges a shell (hung REPL, runaway process, bad terminal mode) the only current recourse is kill_pane + split_window, which destroys pane_id references the agent may still be holding and reshuffles the layout. tmux's respawn-pane -k restarts the process in place, preserving both pane_id and layout — the right primitive for agent recovery flows. what: - Add respawn_pane(pane_id, session_name, session_id, window_id, kill=True, shell_command, start_directory, socket_name) returning PaneInfo. Default kill=True threads -k to tmux (matches the recovery-flow intent). Optional shell_command and start_directory map to tmux's respawn-pane positional arg and -c flag respectively; -e env is deliberately omitted (use set_environment if needed). - Call pane.refresh() after the cmd so _serialize_pane reads the fresh pane_pid and pane_current_command. - Register with ANNOTATIONS_MUTATING + TAG_MUTATING, placed next to kill_pane in the pane lifecycle module. Export from pane_tools __init__. - Add two tests: test_respawn_pane_preserves_pane_id_and_refreshes_pid (pane_id survives, pane_pid changes) and test_respawn_pane_replaces_shell_command (shell_command override takes effect). - Add docs/tools/pane/respawn-pane.md modeled on kill-pane.md plus relaunch-with-different-command example; add the page to the Pane tools index grid and toctree. - Append respawn_pane to the README tool catalog Pane row. Defer respawn_window until respawn_pane shows usage (audit §6.1).
1 parent 8c251b6 commit d5362a9

6 files changed

Lines changed: 205 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Give your AI agent hands inside the terminal — create sessions, run commands,
1818
| **Server** | `list_sessions`, `create_session`, `kill_server`, `get_server_info` |
1919
| **Session** | `list_windows`, `get_session_info`, `create_window`, `rename_session`, `select_window`, `kill_session` |
2020
| **Window** | `list_panes`, `get_window_info`, `split_window`, `rename_window`, `select_layout`, `resize_window`, `move_window`, `kill_window` |
21-
| **Pane** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `display_message`, `select_pane`, `swap_pane`, `resize_pane`, `set_pane_title`, `clear_pane`, `pipe_pane`, `enter_copy_mode`, `exit_copy_mode`, `kill_pane` |
21+
| **Pane** | `send_keys`, `paste_text`, `capture_pane`, `snapshot_pane`, `search_panes`, `get_pane_info`, `wait_for_text`, `wait_for_content_change`, `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` |
2222
| **Options** | `show_option`, `set_option` |
2323
| **Environment** | `show_environment`, `set_environment` |
2424

docs/tools/pane/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ Block until a tmux wait-for channel is signalled.
8181
Signal a waiting channel.
8282
:::
8383

84+
:::{grid-item-card} {tooliconl}`respawn-pane`
85+
Restart a pane's process in place, preserving pane_id.
86+
:::
87+
8488
:::{grid-item-card} {tooliconl}`kill-pane`
8589
Terminate a pane. Destructive.
8690
:::
@@ -110,5 +114,6 @@ wait-for-text
110114
wait-for-content-change
111115
wait-for-channel
112116
signal-channel
117+
respawn-pane
113118
kill-pane
114119
```

docs/tools/pane/respawn-pane.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Respawn pane
2+
3+
```{fastmcp-tool} pane_tools.respawn_pane
4+
```
5+
6+
**Use when** a pane's shell or command has wedged (hung REPL, runaway
7+
process, bad terminal mode) and you need a clean restart *without*
8+
destroying the `pane_id` references other tools or callers may still
9+
be holding. With `kill=True` (the default) tmux kills the current
10+
process first; optional `shell_command` relaunches with a different
11+
command; optional `start_directory` sets its cwd.
12+
13+
**Avoid when** the pane genuinely needs to go away — use
14+
{tooliconl}`kill-pane` instead. Also avoid when you want to change
15+
the layout: `respawn-pane` preserves the pane in place.
16+
17+
**Side effects:** Kills the current process (with `kill=True`) and
18+
starts a new one. **The `pane_id` is preserved** — that's the whole
19+
point of the tool. `pane_pid` updates to the new process.
20+
21+
**Example — recover a wedged pane, relaunching the default shell:**
22+
23+
```json
24+
{
25+
"tool": "respawn_pane",
26+
"arguments": {
27+
"pane_id": "%5"
28+
}
29+
}
30+
```
31+
32+
**Example — relaunch with a different command and working directory:**
33+
34+
```json
35+
{
36+
"tool": "respawn_pane",
37+
"arguments": {
38+
"pane_id": "%5",
39+
"shell_command": "pytest -x",
40+
"start_directory": "/home/user/project"
41+
}
42+
}
43+
```
44+
45+
Response (PaneInfo):
46+
47+
```json
48+
{
49+
"pane_id": "%5",
50+
"pane_pid": "98765",
51+
"pane_current_command": "pytest",
52+
"pane_current_path": "/home/user/project",
53+
...
54+
}
55+
```
56+
57+
```{fastmcp-tool-input} pane_tools.respawn_pane
58+
```

src/libtmux_mcp/tools/pane_tools/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from libtmux_mcp.tools.pane_tools.lifecycle import (
3737
get_pane_info,
3838
kill_pane,
39+
respawn_pane,
3940
set_pane_title,
4041
)
4142
from libtmux_mcp.tools.pane_tools.meta import display_message, snapshot_pane
@@ -61,6 +62,7 @@
6162
"pipe_pane",
6263
"register",
6364
"resize_pane",
65+
"respawn_pane",
6466
"search_panes",
6567
"select_pane",
6668
"send_keys",
@@ -88,6 +90,11 @@ def register(mcp: FastMCP) -> None:
8890
annotations=ANNOTATIONS_DESTRUCTIVE,
8991
tags={TAG_DESTRUCTIVE},
9092
)(kill_pane)
93+
mcp.tool(
94+
title="Respawn Pane",
95+
annotations=ANNOTATIONS_MUTATING,
96+
tags={TAG_MUTATING},
97+
)(respawn_pane)
9198
mcp.tool(
9299
title="Set Pane Title", annotations=ANNOTATIONS_MUTATING, tags={TAG_MUTATING}
93100
)(set_pane_title)

src/libtmux_mcp/tools/pane_tools/lifecycle.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,84 @@ def kill_pane(
5858
return f"Pane killed: {pid}"
5959

6060

61+
@handle_tool_errors
62+
def respawn_pane(
63+
pane_id: str | None = None,
64+
session_name: str | None = None,
65+
session_id: str | None = None,
66+
window_id: str | None = None,
67+
kill: bool = True,
68+
shell_command: str | None = None,
69+
start_directory: str | None = None,
70+
socket_name: str | None = None,
71+
) -> PaneInfo:
72+
"""Restart a pane's process in place, preserving pane_id and layout.
73+
74+
Use when a shell wedges (hung REPL, runaway process, bad terminal
75+
mode). The alternative — kill_pane + split_window — destroys
76+
pane_id references the agent may still be holding, and rearranges
77+
the layout. respawn-pane preserves both.
78+
79+
With ``kill=True`` (the default), tmux kills the existing process
80+
before respawning. Optional ``shell_command`` replaces the
81+
command tmux relaunches; ``start_directory`` sets the working
82+
directory for the new process.
83+
84+
Parameters
85+
----------
86+
pane_id : str, optional
87+
Pane ID (e.g. '%1').
88+
session_name : str, optional
89+
Session name for pane resolution.
90+
session_id : str, optional
91+
Session ID (e.g. '$1') for pane resolution.
92+
window_id : str, optional
93+
Window ID for pane resolution.
94+
kill : bool
95+
When True (default), pass ``-k`` to tmux so the current
96+
process is killed before respawning. When False, respawn
97+
fails if the pane already has a running process.
98+
shell_command : str, optional
99+
Replacement command for tmux to launch. When omitted, tmux
100+
restarts the original shell/command.
101+
start_directory : str, optional
102+
Working directory for the relaunched command (maps to
103+
``respawn-pane -c``).
104+
socket_name : str, optional
105+
tmux socket name.
106+
107+
Returns
108+
-------
109+
PaneInfo
110+
Serialized pane metadata after respawn. The pane_id is
111+
preserved; pane_pid reflects the new process.
112+
"""
113+
server = _get_server(socket_name=socket_name)
114+
pane = _resolve_pane(
115+
server,
116+
pane_id=pane_id,
117+
session_name=session_name,
118+
session_id=session_id,
119+
window_id=window_id,
120+
)
121+
argv: list[str] = ["-t", pane.pane_id or ""]
122+
if kill:
123+
argv.append("-k")
124+
if start_directory is not None:
125+
argv.extend(["-c", start_directory])
126+
if shell_command is not None:
127+
argv.append(shell_command)
128+
result = pane.cmd("respawn-pane", *argv)
129+
if result.stderr:
130+
stderr = " ".join(result.stderr).strip()
131+
msg = f"tmux respawn-pane failed: {stderr}"
132+
raise ToolError(msg)
133+
# Pick up fresh pane_pid and any command/path updates; tmux does
134+
# not invalidate the underlying object on respawn.
135+
pane.refresh()
136+
return _serialize_pane(pane)
137+
138+
61139
@handle_tool_errors
62140
def set_pane_title(
63141
title: str,

tests/test_pane_tools.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
paste_text,
2828
pipe_pane,
2929
resize_pane,
30+
respawn_pane,
3031
search_panes,
3132
select_pane,
3233
send_keys,
@@ -231,6 +232,61 @@ def test_kill_pane(mcp_server: Server, mcp_session: Session) -> None:
231232
assert "killed" in result.lower()
232233

233234

235+
# ---------------------------------------------------------------------------
236+
# respawn_pane tests
237+
# ---------------------------------------------------------------------------
238+
239+
240+
def test_respawn_pane_preserves_pane_id_and_refreshes_pid(
241+
mcp_server: Server, mcp_session: Session
242+
) -> None:
243+
"""respawn_pane keeps the same pane_id but picks up a new pane_pid.
244+
245+
Uses a fresh split so the caller-pane self-guard doesn't fire and
246+
so the test is independent of what the main mcp_pane is running.
247+
"""
248+
window = mcp_session.active_window
249+
new_pane = window.split(shell="sleep 3600")
250+
assert new_pane.pane_id is not None
251+
# Force a read of the original pid before we respawn.
252+
new_pane.refresh()
253+
original_pid = new_pane.pane_pid
254+
255+
result = respawn_pane(
256+
pane_id=new_pane.pane_id,
257+
socket_name=mcp_server.socket_name,
258+
)
259+
assert result.pane_id == new_pane.pane_id, "pane_id must be preserved"
260+
assert result.pane_pid is not None
261+
assert result.pane_pid != original_pid, (
262+
"pane_pid should reflect the new process after respawn"
263+
)
264+
265+
# Cleanup
266+
new_pane.kill()
267+
268+
269+
def test_respawn_pane_replaces_shell_command(
270+
mcp_server: Server, mcp_session: Session
271+
) -> None:
272+
"""respawn_pane with shell_command relaunches with the new command."""
273+
window = mcp_session.active_window
274+
new_pane = window.split(shell="sleep 3600")
275+
assert new_pane.pane_id is not None
276+
277+
result = respawn_pane(
278+
pane_id=new_pane.pane_id,
279+
shell_command="sleep 7200",
280+
socket_name=mcp_server.socket_name,
281+
)
282+
assert result.pane_id == new_pane.pane_id
283+
# pane_current_command reflects the relaunched command.
284+
assert result.pane_current_command is not None
285+
assert "sleep" in result.pane_current_command
286+
287+
new_pane.kill()
288+
289+
234290
# ---------------------------------------------------------------------------
235291
# search_panes tests
236292
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)