Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Added an async `bash()` function to the IPython kernel so `await bash("...")` runs shell commands without blocking the kernel event loop.
- Fixed URLs not opening on click in fullscreen mode on terminals such as Ghostty; clicking a link in the transcript, dock, or overlays now opens it in the browser.

## [0.7.2] - 2026-08-11
Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/prompts/rlm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const IPYTHON_CONTROL_PROMPT = [
"",
"Do not assume IPython is the native runtime of the external thing being investigated. A repository, package, service, dataset, paper, website, benchmark, or API may have its own environment and normal interface. Evaluate external systems through their own interface, then use IPython to coordinate the process and analyze what comes back.",
"",
"When running shell commands from IPython, use `%%bash` cells. If you use `%%bash`, it must be the first line of the code cell: no comments, spaces, blank lines, imports, or Python statements before it. Avoid `!cmd` shell escapes for project commands so shell behavior is explicit and multi-line commands share one shell context.",
'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do use `%%bash`, it must be the first line of the code cell: no comments, spaces, blank lines, imports, or Python statements before it. Avoid `!cmd` shell escapes for project commands so shell behavior is explicit and multi-line commands share one shell context.',
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No streaming: %%bash shows output as it runs, bash() buffers everything until the command exits. For a long build the model and the user watching see nothing until the end — and since only the tail is kept, the beginning is gone. Fine for short commands, but the prompt recommends bash() unconditionally. Worth either mentioning "use %%bash for long-running commands where you want live output" here, or treating streaming as a follow-up.

"",
"Important: do not install dependencies into the IPython kernel just to make an external project import or run there. If a project import, test, script, CLI, or dependency check is needed, run it through that project's own environment and normal command interface. For example, in a Python repo use its documented commands, `uv run ...`, `.venv/bin/python ...`, or the active project interpreter from the repo root. Treat failures from that native environment as the relevant result.",
"",
Expand Down
212 changes: 212 additions & 0 deletions packages/coding-agent/src/core/tools/ipython.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,218 @@ try:
except Exception:
pass

import asyncio as _prime_agent_asyncio
import subprocess as _prime_agent_subprocess
import shutil as _prime_agent_shutil
import signal as _prime_agent_signal
import os as _prime_agent_bash_os

_PRIME_AGENT_BASH_CAPTURE_LIMIT = 32 * 1024
_PRIME_AGENT_BASH_READ_CHUNK = 64 * 1024
_PRIME_AGENT_BASH_STOP_GRACE = 0.25


class _PrimeAgentBoundedBytes:
"""Tail buffer with a fixed memory ceiling and total-byte accounting."""
def __init__(self, limit: int):
self.limit = limit
self.data = bytearray()
self.total = 0

def append(self, chunk: bytes) -> None:
self.total += len(chunk)
if len(chunk) >= self.limit:
self.data[:] = chunk[-self.limit:]
return
overflow = len(self.data) + len(chunk) - self.limit
if overflow > 0:
del self.data[:overflow]
self.data.extend(chunk)

@property
def truncated(self) -> bool:
return self.total > len(self.data)

def text(self) -> str:
# latin-1 is byte-preserving: retained bytes cannot expand into multiple
# Unicode replacement bytes when IPython serializes the execute result.
return bytes(self.data).decode("latin-1")
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved


class _PrimeAgentBashResult:
"""Result of an async bash command with bounded captured output."""
def __init__(
self,
stdout: str,
stderr: str,
returncode: int,
*,
stdout_truncated: bool = False,
stderr_truncated: bool = False,
stdout_total_bytes: int = 0,
stderr_total_bytes: int = 0,
):
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
self.stdout_truncated = stdout_truncated
self.stderr_truncated = stderr_truncated
self.stdout_total_bytes = stdout_total_bytes
self.stderr_total_bytes = stderr_total_bytes

def __str__(self) -> str:
parts = []
if self.stdout:
parts.append(self.stdout)
if self.stdout_truncated:
parts.append(f"[stdout truncated; showing final {len(self.stdout.encode('latin-1'))} of {self.stdout_total_bytes} bytes]")
if self.stderr:
parts.append(self.stderr)
if self.stderr_truncated:
parts.append(f"[stderr truncated; showing final {len(self.stderr.encode('latin-1'))} of {self.stderr_total_bytes} bytes]")
text = chr(10).join(parts)
if self.returncode != 0:
text += chr(10) + chr(10) + f"Command exited with code {self.returncode}"
return text

def __repr__(self) -> str:
# IPython renders a cell's final expression with repr(), so expose the
# captured command output instead of hiding it behind a metadata summary.
return str(self)
Comment thread
sethkarten marked this conversation as resolved.
Resolved


async def _prime_agent_bash_drain(stream, capture: _PrimeAgentBoundedBytes) -> None:
while True:
chunk = await stream.read(_PRIME_AGENT_BASH_READ_CHUNK)
if not chunk:
return
capture.append(chunk)


async def _prime_agent_bash_collect(proc, stdout_task, stderr_task) -> None:
# asyncio's process wait can remain pending while descendants hold inherited
# pipes, even after the shell's returncode is set. Observe that state directly.
while proc.returncode is None:
await _prime_agent_asyncio.sleep(0.01)
# A successful shell may leave background descendants in its session. Stop

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This kills background jobs even on success — by design, and the tests pin it. But that's a real behavior difference from %%bash: with bash() you can never start a long-lived background process (e.g. nohup server &). The prompt says "prefer await bash(...)" without mentioning this, so a model following the doctrine will launch a dev server and find it dead. One extra clause in the prompt line would prevent that.

# them before waiting for inherited pipe descriptors to close.
_prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM)
drains = _prime_agent_asyncio.gather(stdout_task, stderr_task)
try:
await _prime_agent_asyncio.wait_for(
_prime_agent_asyncio.shield(drains), timeout=_PRIME_AGENT_BASH_STOP_GRACE
)
except _prime_agent_asyncio.TimeoutError:
_prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL)
await drains


def _prime_agent_bash_signal_group(proc, sig) -> None:
# The shell may have exited while descendants still hold its pipes open, so
# always signal the session by the original leader PID.
try:
_prime_agent_bash_os.killpg(proc.pid, sig)
except (ProcessLookupError, PermissionError):
pass




async def _prime_agent_bash_stop(proc, collector, stdout_task, stderr_task) -> None:
"""Stop the command tree and finish or cancel every pipe-drain task."""
_prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGTERM)
try:
await _prime_agent_asyncio.wait_for(
_prime_agent_asyncio.shield(collector), timeout=_PRIME_AGENT_BASH_STOP_GRACE
)
return
except _prime_agent_asyncio.TimeoutError:
_prime_agent_bash_signal_group(proc, _prime_agent_signal.SIGKILL)

try:
await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=2)
return
except _prime_agent_asyncio.TimeoutError:
if proc.returncode is None:
proc.kill()
finally:
if not collector.done():
collector.cancel()
for task in (stdout_task, stderr_task):
if not task.done():
task.cancel()
await _prime_agent_asyncio.gather(collector, stdout_task, stderr_task, return_exceptions=True)


async def bash(
command: str,
*,
timeout: float | None = None,
cwd: str | None = None,
max_output_bytes: int = _PRIME_AGENT_BASH_CAPTURE_LIMIT,
) -> _PrimeAgentBashResult:
"""Run a shell command without blocking the IPython kernel event loop.

At most max_output_bytes from the tail of each output stream is retained;
both pipes are continuously drained so large output cannot deadlock or grow
the persistent kernel without bound. Truncation and total byte counts are
reported on the result.
"""
if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes <= 0:
raise ValueError("max_output_bytes must be a positive integer")
if _prime_agent_bash_os.name == "nt":
raise RuntimeError("bash() is unavailable on Windows because reliable process-tree cleanup is not supported")
shell = _prime_agent_shutil.which("bash") or "/bin/bash"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bash() hardcodes the shell and skips the user's shell settings. The %%bash path goes through applyShellSettingsToBashMagicCell(), which applies the configured commandPrefix and shellPath. Users who set those get them silently ignored on the now-preferred path. Suggest either applying the same settings here or noting in the prompt that bash() ignores them.

env = dict(_prime_agent_bash_os.environ)
work_dir = cwd or _prime_agent_bash_os.getcwd()
spawn_options = {"start_new_session": True}

proc = await _prime_agent_asyncio.create_subprocess_exec(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
Comment thread
sethkarten marked this conversation as resolved.
Resolved
shell, "-c", command,
stdin=_prime_agent_subprocess.DEVNULL,
stdout=_prime_agent_subprocess.PIPE,
stderr=_prime_agent_subprocess.PIPE,
cwd=work_dir,
env=env,
limit=_PRIME_AGENT_BASH_READ_CHUNK,
**spawn_options,
)
stdout_capture = _PrimeAgentBoundedBytes(max_output_bytes)
stderr_capture = _PrimeAgentBoundedBytes(max_output_bytes)
stdout_task = _prime_agent_asyncio.create_task(_prime_agent_bash_drain(proc.stdout, stdout_capture))
stderr_task = _prime_agent_asyncio.create_task(_prime_agent_bash_drain(proc.stderr, stderr_capture))
collector = _prime_agent_asyncio.create_task(_prime_agent_bash_collect(proc, stdout_task, stderr_task))

try:
await _prime_agent_asyncio.wait_for(_prime_agent_asyncio.shield(collector), timeout=timeout)
except _prime_agent_asyncio.TimeoutError:
cleanup = _prime_agent_asyncio.create_task(
_prime_agent_bash_stop(proc, collector, stdout_task, stderr_task)
)
await _prime_agent_asyncio.shield(cleanup)
raise TimeoutError(f"bash command timed out after {timeout}s") from None
except BaseException:
cleanup = _prime_agent_asyncio.create_task(
_prime_agent_bash_stop(proc, collector, stdout_task, stderr_task)
)
await _prime_agent_asyncio.shield(cleanup)
raise
finally:
if not collector.done() and proc.returncode is not None:
collector.cancel()
await _prime_agent_asyncio.gather(collector, return_exceptions=True)

return _PrimeAgentBashResult(
stdout=stdout_capture.text(),
stderr=stderr_capture.text(),
returncode=proc.returncode if proc.returncode is not None else -1,
stdout_truncated=stdout_capture.truncated,
stderr_truncated=stderr_capture.truncated,
stdout_total_bytes=stdout_capture.total,
stderr_total_bytes=stderr_capture.total,
)


try:
import rlm as _prime_agent_rlm_module
rlm = _prime_agent_rlm_module.rlm
Expand Down
123 changes: 123 additions & 0 deletions packages/coding-agent/test/ipython-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ describe("IPython RLM bootstrap", () => {
expect(buildRlmBootstrapCode()).toContain('_prime_agent_os.environ["NO_COLOR"] = "1"');
});

it("bounds async bash output and cleans POSIX process groups on exceptional exits", () => {
const code = buildRlmBootstrapCode();
expect(code).not.toContain("proc.communicate()");
expect(code).toContain("_PrimeAgentBoundedBytes");
expect(code).toContain("start_new_session");
expect(code).toContain("killpg");
expect(code).toContain("except BaseException");
expect(code).toContain("bash() is unavailable on Windows");
expect(code).toContain("isinstance(max_output_bytes, bool)");
expect(code).not.toContain("taskkill");
});

it("guards Python skill imports so a broken skill does not abort bootstrap", () => {
const code = buildRlmBootstrapCode([
{
Expand Down Expand Up @@ -83,6 +95,117 @@ describeIfKernel("IPython RLM bootstrap (real kernel)", () => {
}
}, 60_000);

it("renders command output when await bash is the final expression", async () => {
const manager = new KernelManager({ python: python as string, cwd: dir });
try {
await manager.start();
expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok");
const result = await manager.execute('await bash("printf visible-output")');
expect(result.status).toBe("ok");
expect(result.result).toContain("visible-output");
} finally {
await manager.dispose();
}
}, 60_000);

it("runs async bash with bounded stdout and stderr capture", async () => {
const manager = new KernelManager({ python: python as string, cwd: dir });
try {
await manager.start();
expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok");
const code = [
"import sys",
'r = await bash("yes A | head -c 200000; yes B | head -c 200000 >&2", max_output_bytes=4096)',
"print(r.returncode, len(r.stdout), len(r.stderr), r.stdout_truncated, r.stderr_truncated)",
"print(r.stdout_total_bytes, r.stderr_total_bytes)",
'print(str(r).count("truncated"))',
].join("\n");
const result = await manager.execute(code);
expect(result.status).toBe("ok");
expect(result.stdout).toContain("0 4096 4096 True True");
expect(result.stdout).toContain("200000 200000");
expect(result.stdout).toContain("2");
} finally {
await manager.dispose();
}
}, 60_000);

it("keeps malformed byte output within the rendered capture bound", async () => {
const manager = new KernelManager({ python: python as string, cwd: dir });
try {
await manager.start();
expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok");
const result = await manager.execute(
'r = await bash("yes ÿ | head -c 100000", max_output_bytes=4096)\nprint(len(r.stdout), len(repr(r)), r.stdout_truncated)',
);
expect(result.status).toBe("ok");
expect(result.stdout).toContain("4096");
expect(result.stdout).toContain("True");
} finally {
await manager.dispose();
}
}, 60_000);

it.skipIf(process.platform === "win32")(
"cleans background descendants after the shell exits",
async () => {
const manager = new KernelManager({ python: python as string, cwd: dir });
const marker = join(dir, "background-marker");
try {
await manager.start();
expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok");
const result = await manager.execute(
`r = await bash("(sleep 1; touch '${marker}') &")\nprint(r.returncode)`,
);
expect(result.status).toBe("ok");
expect(result.stdout).toContain("0");
await new Promise((resolve) => setTimeout(resolve, 1300));
expect(existsSync(marker)).toBe(false);
} finally {
await manager.dispose();
}
},
60_000,
);

it.skipIf(process.platform === "win32")(
"kills descendants on timeout and cancellation",
async () => {
const manager = new KernelManager({ python: python as string, cwd: dir });
try {
await manager.start();
expect((await manager.execute(buildRlmBootstrapCode())).status).toBe("ok");
const timeoutMarker = join(dir, "timeout-marker");
const cancelMarker = join(dir, "cancel-marker");
const timeout = await manager.execute(
`try:
await bash("(sleep 1; touch '${timeoutMarker}') & wait", timeout=0.1)
except TimeoutError:
print("timed-out")`,
);
expect(timeout.status).toBe("ok");
expect(timeout.stdout).toContain("timed-out");
const cancelled = await manager.execute(
`task = asyncio.create_task(bash("(sleep 1; touch '${cancelMarker}') & wait"))
await asyncio.sleep(0.1)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelled")`,
);
expect(cancelled.status).toBe("ok");
expect(cancelled.stdout).toContain("cancelled");
await new Promise((resolve) => setTimeout(resolve, 1300));
expect(existsSync(timeoutMarker)).toBe(false);
expect(existsSync(cancelMarker)).toBe(false);
} finally {
await manager.dispose();
}
},
60_000,
);

it("emits canonical paths for edits after the kernel changes directories", async () => {
const firstDir = join(dir, "first");
const secondDir = join(dir, "second");
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/test/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("buildRlmPrompt", () => {
"",
"Do not assume IPython is the native runtime of the external thing being investigated. A repository, package, service, dataset, paper, website, benchmark, or API may have its own environment and normal interface. Evaluate external systems through their own interface, then use IPython to coordinate the process and analyze what comes back.",
"",
"When running shell commands from IPython, use `%%bash` cells. If you use `%%bash`, it must be the first line of the code cell: no comments, spaces, blank lines, imports, or Python statements before it. Avoid `!cmd` shell escapes for project commands so shell behavior is explicit and multi-line commands share one shell context.",
'When running shell commands from IPython, prefer `await bash("...")` over `%%bash` cells: it keeps the kernel event loop responsive to interrupts, cleans up its managed process group on interruption, and bounds captured stdout/stderr. `%%bash` cells block the kernel until the command finishes. If you do use `%%bash`, it must be the first line of the code cell: no comments, spaces, blank lines, imports, or Python statements before it. Avoid `!cmd` shell escapes for project commands so shell behavior is explicit and multi-line commands share one shell context.',
"",
"Important: do not install dependencies into the IPython kernel just to make an external project import or run there. If a project import, test, script, CLI, or dependency check is needed, run it through that project's own environment and normal command interface. For example, in a Python repo use its documented commands, `uv run ...`, `.venv/bin/python ...`, or the active project interpreter from the repo root. Treat failures from that native environment as the relevant result.",
"",
Expand Down Expand Up @@ -98,6 +98,7 @@ describe("buildRlmPrompt", () => {
expect(prompt).toContain("A callable `rlm` is already in your global namespace");
expect(prompt).toContain("IPython is the agent's long-lived notebook");
expect(prompt).toContain("Each `%%bash` cell runs in a throw-away subshell");
expect(prompt).toContain("prefer `await bash");
});

test("discovers requested models through a bounded authenticated host search", () => {
Expand Down