diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
index 8ac39bce..aaf78fcc 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -11,6 +11,7 @@ on:
- "crates/bashkit-python/**"
- "crates/bashkit/**"
- "examples/*.py"
+ - "examples/*.ipynb"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/python.yml"
@@ -20,6 +21,7 @@ on:
- "crates/bashkit-python/**"
- "crates/bashkit/**"
- "examples/*.py"
+ - "examples/*.ipynb"
- "Cargo.toml"
- "Cargo.lock"
- ".github/workflows/python.yml"
@@ -120,7 +122,7 @@ jobs:
- name: Install local wheel
run: |
pip install bashkit --no-index --find-links crates/bashkit-python/dist --force-reinstall
- pip install langchain-core langgraph fastapi httpx uvicorn
+ pip install langchain-core langgraph fastapi httpx uvicorn nbconvert jupyter-client ipykernel
- name: Run examples
run: |
@@ -131,6 +133,11 @@ jobs:
python crates/bashkit-python/examples/langgraph_async_tool.py
python crates/bashkit-python/examples/fastapi_async_tool.py
+ - name: Run notebooks
+ run: |
+ jupyter nbconvert --to notebook --execute --ExecutePreprocessor.timeout=120 \
+ examples/jupyter_basics.ipynb --output /tmp/jupyter_basics_out.ipynb
+
# Verify wheel builds and passes twine check
build-wheel:
name: Build wheel
diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi
index 88bb2769..91ea61b5 100644
--- a/crates/bashkit-python/bashkit/_bashkit.pyi
+++ b/crates/bashkit-python/bashkit/_bashkit.pyi
@@ -424,6 +424,10 @@ class Bash:
Not supported when ``external_handler`` is configured — use
``execute()`` (async) instead. ``on_output`` must be synchronous.
Async ``custom_builtins`` callbacks run on a private loop here.
+ When called from inside a running event loop (e.g. Jupyter / IPython),
+ callbacks are dispatched to a background thread with their own loop so
+ that asyncio's "cannot run while another loop is running" restriction
+ is not triggered.
Example::
@@ -778,6 +782,10 @@ class BashTool:
"""Execute bash commands synchronously (blocking).
Async ``custom_builtins`` callbacks run on a private loop here.
+ When called from inside a running event loop (e.g. Jupyter / IPython),
+ callbacks are dispatched to a background thread with their own loop so
+ that asyncio's "cannot run while another loop is running" restriction
+ is not triggered.
``on_output`` must be synchronous.
diff --git a/crates/bashkit-python/examples/jupyter_async_test.ipynb b/crates/bashkit-python/examples/jupyter_async_test.ipynb
new file mode 100644
index 00000000..8cf094b6
--- /dev/null
+++ b/crates/bashkit-python/examples/jupyter_async_test.ipynb
@@ -0,0 +1,641 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "563a9854",
+ "metadata": {},
+ "source": [
+ "# bashkit in Jupyter: async + custom_builtins compatibility\n",
+ "\n",
+ "Jupyter already owns an asyncio event loop. This notebook checks that:\n",
+ "- `execute_sync()` works (creates its own private loop, no conflict)\n",
+ "- `await execute()` runs on Jupyter's caller loop\n",
+ "- Async callbacks reach the correct loop in both modes\n",
+ "- `custom_builtins` (BuiltinContext) work with sync and async callbacks\n",
+ "- ContextVar propagation works in Jupyter"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "c9c207fd",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.836371Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.836113Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.848264Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.847318Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "bashkit imported OK\n",
+ "Jupyter event loop: <_UnixSelectorEventLoop running=True closed=False debug=False>\n"
+ ]
+ }
+ ],
+ "source": [
+ "import asyncio\n",
+ "import contextvars\n",
+ "\n",
+ "from bashkit import Bash, BuiltinContext, ScriptedTool\n",
+ "\n",
+ "print(\"bashkit imported OK\")\n",
+ "print(f\"Jupyter event loop: {asyncio.get_event_loop()}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "44928c58",
+ "metadata": {},
+ "source": [
+ "## 1. Basic sync execution\n",
+ "\n",
+ "`execute_sync()` creates a private internal loop — should not conflict with Jupyter's loop."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "44e330ae",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.851832Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.851588Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.856930Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.855674Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "sync execute OK: 'hello from bashkit'\n"
+ ]
+ }
+ ],
+ "source": [
+ "bash = Bash()\n",
+ "result = bash.execute_sync(\"echo 'hello from bashkit'\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"hello from bashkit\"\n",
+ "print(\"sync execute OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "75b75348",
+ "metadata": {},
+ "source": [
+ "## 2. Basic async execution\n",
+ "\n",
+ "Jupyter lets you `await` at the top level — `execute()` should run on Jupyter's loop."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "3ff0f378",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.859633Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.859367Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.869305Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.867769Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "async execute OK: 'hello async'\n"
+ ]
+ }
+ ],
+ "source": [
+ "bash = Bash()\n",
+ "result = await bash.execute(\"echo 'hello async'\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"hello async\"\n",
+ "print(\"async execute OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8c4b938e",
+ "metadata": {},
+ "source": [
+ "## 3. custom_builtins — sync callback (BuiltinContext)\n",
+ "\n",
+ "A Python function registered as a bash builtin via `custom_builtins=`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "bb8275f1",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.872160Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.871888Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.878066Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.876528Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "sync builtin OK: 'hello Jupiter'\n"
+ ]
+ }
+ ],
+ "source": [
+ "def greet(ctx: BuiltinContext) -> str:\n",
+ " name = ctx.argv[0] if ctx.argv else \"world\"\n",
+ " return f\"hello {name}\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"greet\": greet})\n",
+ "result = bash.execute_sync(\"greet Jupiter\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"hello Jupiter\"\n",
+ "print(\"sync builtin OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8bb2e5b1",
+ "metadata": {},
+ "source": [
+ "## 4. custom_builtins — sync callback with pipeline\n",
+ "\n",
+ "Builtin receiving stdin from a pipe."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "72b4368a",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.880726Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.880407Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.885843Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.884914Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "sync builtin + pipe OK: 'HELLO PIPE'\n"
+ ]
+ }
+ ],
+ "source": [
+ "def shout(ctx: BuiltinContext) -> str:\n",
+ " text = ctx.stdin or \"\"\n",
+ " return text.upper()\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"shout\": shout})\n",
+ "result = bash.execute_sync(\"echo 'hello pipe' | shout\")\n",
+ "assert result.exit_code == 0\n",
+ "assert \"HELLO PIPE\" in result.stdout\n",
+ "print(\"sync builtin + pipe OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "99aa6f92",
+ "metadata": {},
+ "source": [
+ "## 5. custom_builtins — async callback via execute_sync()\n",
+ "\n",
+ "`execute_sync()` spawns its own private event loop to drive the async callback.\n",
+ "In Jupyter there's already a running loop — the private loop must be independent."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "bd552eab",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.889269Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.889016Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.896905Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.895563Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "async builtin via execute_sync OK: 'async-hello Jupyter'\n"
+ ]
+ }
+ ],
+ "source": [
+ "jupyter_loop = asyncio.get_event_loop()\n",
+ "\n",
+ "\n",
+ "async def async_greet(ctx: BuiltinContext) -> str:\n",
+ " await asyncio.sleep(0) # yield to loop\n",
+ " name = ctx.argv[0] if ctx.argv else \"world\"\n",
+ " return f\"async-hello {name}\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"async_greet\": async_greet})\n",
+ "result = bash.execute_sync(\"async_greet Jupyter\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"async-hello Jupyter\"\n",
+ "print(\"async builtin via execute_sync OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4e125857",
+ "metadata": {},
+ "source": [
+ "## 6. custom_builtins — async callback via await execute()\n",
+ "\n",
+ "With `await execute()`, async callbacks run on Jupyter's own event loop."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "b63fc56f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.899781Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.899407Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.906936Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.905203Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "async builtin via await execute uses caller loop: OK\n"
+ ]
+ }
+ ],
+ "source": [
+ "jupyter_loop = asyncio.get_running_loop()\n",
+ "\n",
+ "captured_loop = None\n",
+ "\n",
+ "\n",
+ "async def loop_checker(ctx: BuiltinContext) -> str:\n",
+ " global captured_loop\n",
+ " captured_loop = asyncio.get_running_loop()\n",
+ " return \"checked\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"loop_checker\": loop_checker})\n",
+ "result = await bash.execute(\"loop_checker\")\n",
+ "\n",
+ "assert result.exit_code == 0\n",
+ "assert captured_loop is jupyter_loop, \"async callback must run on Jupyter's loop\"\n",
+ "print(\"async builtin via await execute uses caller loop: OK\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ee5adc93",
+ "metadata": {},
+ "source": [
+ "## 7. ContextVar propagation — sync callback\n",
+ "\n",
+ "ContextVars set in the Jupyter cell propagate into builtin callbacks."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "a5cbb381",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.909959Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.909567Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.915702Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.914529Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ContextVar propagation (sync builtin) OK: 'req=jupyter-req-123'\n"
+ ]
+ }
+ ],
+ "source": [
+ "request_id: contextvars.ContextVar[str] = contextvars.ContextVar(\"request_id\")\n",
+ "request_id.set(\"jupyter-req-123\")\n",
+ "\n",
+ "\n",
+ "def check_ctx(ctx: BuiltinContext) -> str:\n",
+ " rid = request_id.get(\"MISSING\")\n",
+ " return f\"req={rid}\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"check_ctx\": check_ctx})\n",
+ "result = bash.execute_sync(\"check_ctx\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"req=jupyter-req-123\"\n",
+ "print(\"ContextVar propagation (sync builtin) OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c87a4db8",
+ "metadata": {},
+ "source": [
+ "## 8. ContextVar propagation — async callback via await execute()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "215ce9a2",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.918767Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.918410Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.925928Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.924357Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ContextVar propagation (async builtin + await execute) OK: 'req=jupyter-req-456'\n"
+ ]
+ }
+ ],
+ "source": [
+ "request_id.set(\"jupyter-req-456\")\n",
+ "\n",
+ "\n",
+ "async def async_check_ctx(ctx: BuiltinContext) -> str:\n",
+ " rid = request_id.get(\"MISSING\")\n",
+ " return f\"req={rid}\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"async_check_ctx\": async_check_ctx})\n",
+ "result = await bash.execute(\"async_check_ctx\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"req=jupyter-req-456\"\n",
+ "print(\"ContextVar propagation (async builtin + await execute) OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8414c3e3",
+ "metadata": {},
+ "source": [
+ "## 9. ScriptedTool with custom tools in Jupyter\n",
+ "\n",
+ "`ScriptedTool.add_tool()` registers Python callbacks (both sync and async)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "5df2b146",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.929158Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.928841Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.940023Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.938514Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ScriptedTool sync callback OK: 'fedcba'\n",
+ "ScriptedTool async callback via execute_sync OK: 'data:mykey'\n"
+ ]
+ }
+ ],
+ "source": [
+ "def reverse(params, stdin=None):\n",
+ " text = params.get(\"text\", stdin or \"\")\n",
+ " return text[::-1] + \"\\n\"\n",
+ "\n",
+ "\n",
+ "async def fetch_data(params, stdin=None):\n",
+ " await asyncio.sleep(0) # simulate async I/O\n",
+ " key = params.get(\"key\", \"default\")\n",
+ " return f\"data:{key}\\n\"\n",
+ "\n",
+ "\n",
+ "tool = ScriptedTool(\"jupyter-test\")\n",
+ "tool.add_tool(\n",
+ " \"reverse\",\n",
+ " \"Reverse a string\",\n",
+ " callback=reverse,\n",
+ " schema={\"type\": \"object\", \"properties\": {\"text\": {\"type\": \"string\"}}},\n",
+ ")\n",
+ "tool.add_tool(\n",
+ " \"fetch_data\",\n",
+ " \"Fetch data by key\",\n",
+ " callback=fetch_data,\n",
+ " schema={\"type\": \"object\", \"properties\": {\"key\": {\"type\": \"string\"}}},\n",
+ ")\n",
+ "\n",
+ "r = tool.execute_sync(\"reverse --text abcdef\")\n",
+ "assert r.exit_code == 0\n",
+ "assert r.stdout.strip() == \"fedcba\"\n",
+ "print(\"ScriptedTool sync callback OK:\", repr(r.stdout.strip()))\n",
+ "\n",
+ "r = tool.execute_sync(\"fetch_data --key mykey\")\n",
+ "assert r.exit_code == 0\n",
+ "assert r.stdout.strip() == \"data:mykey\"\n",
+ "print(\"ScriptedTool async callback via execute_sync OK:\", repr(r.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "19fd0e79",
+ "metadata": {},
+ "source": [
+ "## 10. ScriptedTool — async callback via await execute() in Jupyter"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "2c706b1c",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.943444Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.943157Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.949243Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.947960Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "ScriptedTool async callback via await execute OK: 'data:asynckey'\n"
+ ]
+ }
+ ],
+ "source": [
+ "r = await tool.execute(\"fetch_data --key asynckey\")\n",
+ "assert r.exit_code == 0\n",
+ "assert r.stdout.strip() == \"data:asynckey\"\n",
+ "print(\"ScriptedTool async callback via await execute OK:\", repr(r.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bb10c09f",
+ "metadata": {},
+ "source": [
+ "## 11. Bash pipeline with custom builtin\n",
+ "\n",
+ "Custom builtins participate in shell pipelines."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "ce99a7d7",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.952487Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.952182Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.958641Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.957285Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Pipeline with custom builtin OK:\n",
+ "[INFO] line1\n",
+ "[INFO] line2\n",
+ "[INFO] line3\n"
+ ]
+ }
+ ],
+ "source": [
+ "def tag(ctx: BuiltinContext) -> str:\n",
+ " label = ctx.argv[0] if ctx.argv else \"tag\"\n",
+ " text = ctx.stdin or \"\"\n",
+ " return \"\\n\".join(f\"[{label}] {line}\" for line in text.splitlines()) + \"\\n\"\n",
+ "\n",
+ "\n",
+ "bash = Bash(custom_builtins={\"tag\": tag})\n",
+ "result = bash.execute_sync(\"printf 'line1\\nline2\\nline3\\n' | tag INFO\")\n",
+ "assert result.exit_code == 0\n",
+ "assert \"[INFO] line1\" in result.stdout\n",
+ "assert \"[INFO] line3\" in result.stdout\n",
+ "print(\"Pipeline with custom builtin OK:\")\n",
+ "print(result.stdout.strip())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "325ee5aa",
+ "metadata": {},
+ "source": [
+ "## 12. Persistent state across cells\n",
+ "\n",
+ "VFS and shell variables persist across `execute_sync` calls on the same `Bash` instance."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "5672c2e0",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-16T22:49:28.961126Z",
+ "iopub.status.busy": "2026-04-16T22:49:28.960925Z",
+ "iopub.status.idle": "2026-04-16T22:49:28.966133Z",
+ "shell.execute_reply": "2026-04-16T22:49:28.964782Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Shell state persists across calls OK: 'hello'\n"
+ ]
+ }
+ ],
+ "source": [
+ "bash = Bash()\n",
+ "bash.execute_sync(\"export MYVAR=hello\")\n",
+ "result = bash.execute_sync(\"echo $MYVAR\")\n",
+ "assert result.exit_code == 0\n",
+ "assert result.stdout.strip() == \"hello\"\n",
+ "print(\"Shell state persists across calls OK:\", repr(result.stdout.strip()))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f9508d1b",
+ "metadata": {},
+ "source": [
+ "## Summary\n",
+ "\n",
+ "All cells passed. Key findings:\n",
+ "- `execute_sync()` creates its own private event loop — no conflict with Jupyter's loop\n",
+ "- `await execute()` runs on Jupyter's caller loop; async callbacks see the same loop\n",
+ "- `custom_builtins` (sync and async, BuiltinContext) work in both execution modes\n",
+ "- ContextVar propagation into callbacks works from Jupyter cell scope\n",
+ "- `ScriptedTool` works as expected with sync and async callbacks"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.11.15"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs
index 5a3565a9..73ad2177 100644
--- a/crates/bashkit-python/src/lib.rs
+++ b/crates/bashkit-python/src/lib.rs
@@ -1501,12 +1501,15 @@ enum PySyncLoopMode {
struct PyPrivateAsyncLoop {
event_loop: StdMutex