None of the other answers here worked for our use case for various reasons, but after a fair amount of hacking we found the following solution. Some work is still needed to put this to use, but it should be enough to get started; just run each of the following blocks in a Jupyter notebook.
First, spool up a remote kernel for us to connect to:
import IPython
import inspect
test_var = "HELLO WORLD"
def embed():
caller_frame = inspect.stack()[1][0]
scope = {}
scope.update(caller_frame.f_globals)
scope.update(caller_frame.f_locals)
IPython.embed_kernel(local_ns=scope)
embed()
Now we connect to the remote kernel using a BlockingKernelClient
import jupyter_client
connection_file = jupyter_client.find_connection_file("kernel-1337.json")
client = jupyter_client.BlockingKernelClient()
client.load_connection_file(connection_file)
client.start_channels()
At this point, we're able to send text to the remote kernel and have it interpreted as python. Note that test_var was never defined in our notebook's kernel, but we can still print it out like normal:
client.execute_interactive("print(test_var)")

Plotting works like normal too, even though we're sending data to the remote shell:
client.execute_interactive("import matplotlib.pyplot as plt; plt.scatter([1], [1])");

Now we just need to hide this hack from the user. We can do that using ipython input transformer hooks:
def parse_to_remote(lines):
new_lines = []
for line in lines:
line_wrapped = f"client.execute_interactive({repr(line)});"
new_lines.append(line_wrapped)
return new_lines
ipy = get_ipython()
ipy.input_transformers_post.append(parse_to_remote)
del parse_to_remote
The del parse_to_remote
is important; without it, the local kernel will crash.
After this point, any cells you run in your notebook will be parsed by the remote kernel instead of the local one, and outputs should just work like normal. If we print the remote-side test_var
again, we can see that the code is running in the remote kernel (not the local, jupyter-spawned one):
print(test_var)

At this point, the Jupyter notebook will act as if it were connected directly to the remote kernel. Restarting the notebook will just reconnect to the remote kernel without restarting it.