- TypeScript 100%
|
|
||
|---|---|---|
| .github/workflows | ||
| docs | ||
| .gitignore | ||
| AGENTS.md | ||
| artifacts.test.ts | ||
| artifacts.ts | ||
| bun.lock | ||
| CONTEXT.md | ||
| context.test.ts | ||
| context.ts | ||
| core.ts | ||
| diagnostics.test.ts | ||
| diagnostics.ts | ||
| host.test.ts | ||
| host.ts | ||
| index.test.ts | ||
| index.ts | ||
| journal.test.ts | ||
| journal.ts | ||
| launcher.ts | ||
| LICENSE | ||
| output-artifacts.test.ts | ||
| output-artifacts.ts | ||
| package.json | ||
| README.md | ||
| registered-tools.ts | ||
| rendering.test.ts | ||
| rendering.ts | ||
| runner.test.ts | ||
| runner.ts | ||
| tsconfig.json | ||
Pi Code Execution
A Pi extension that runs short CPython scripts as the code_execution tool.
It is useful for data transformation, dependent operations, and filtering large
intermediate results before they enter model context.
Install
This package supports POSIX systems. Install
uv, then install
the package:
pi install git:github.com/jawfish/pi-code-execution
Pi loads index.ts from the package manifest. To try a checkout without
installing it:
pi --no-extensions -e .
Features
- Runs full CPython in a fresh process for each call, or keeps state across calls in a named session.
- Supports top-level
await. - Prints the value of a final expression, as a REPL does.
- Resolves relative paths and local imports from the Pi session directory.
- Installs script dependencies declared with
PEP 723 through
uv. - Streams stdout and stderr while the script runs, keeps bounded live and final previews, and retains up to 64 MiB in a verified output artifact.
- Runs sequentially with sibling calls in the same Pi tool batch, so they cannot race with the script's filesystem or subprocess work.
- Keeps explicit concurrency inside one script: bridged calls passed to
asyncio.gather()may still run at the same time. - Applies a configurable deadline to dependency installation, Python, and bridged tool calls.
- Stores older script sources as verified content-addressed artifacts, then
replaces them with a short
sourceIdin model context. - Replays an exact saved script from its verified
sourceId. - Reads saved source without execution through
code_execution_source. - Recovers retained output without rerunning side effects through
code_execution_output. - Exposes every active Pi tool to Python through an async, auditable
nested-call lifecycle. Tool results are strings with a
json()parser.
When the last top-level statement is an expression, the run prints its value
after the script's own output. A str prints as text, so a tool result keeps
its line breaks. Other values are pretty-printed with pprint, in insertion
order, at a width of 120. None prints nothing, so a final print() or
await asyncio.sleep() adds no output. A semicolon after the expression
suppresses the value, as in IPython:
rows = (await plane_workitem(action="list", project_id=PROJECT)).json()
[row["name"] for row in rows if row["priority"] == "urgent"]
pathlib.Path("out.json").write_text(json.dumps(rows));
The run report records whether a value was printed as displayed_value.
Example with a third-party dependency:
# /// script
# dependencies = ["httpx"]
# ///
import httpx
response = httpx.get("https://example.com")
print(response.status_code)
Set PI_CODE_EXECUTION_UV to use a specific uv executable:
PI_CODE_EXECUTION_UV=/opt/uv/bin/uv pi
Sessions
Pass session: "<name>" to run in a persistent Python process. Runs that name
the same session share its globals, imports, functions, open clients, and
asyncio event loop. Omit session and each call gets a fresh process.
# session: "data"
rows = (await plane_workitem(action="list", project_id=PROJECT)).json()
# session: "data", a later call
print(len(rows), rows[0]["name"])
The event loop runs only while a run executes. A task created in one run
does not advance between runs; it continues in the next run that awaits.
Threads keep running between runs. A session keeps its current directory, so
an os.chdir() in one run changes where relative paths resolve in the next.
Each run keeps its own output, deadline, run report, script activity, and nested calls. The session process writes a random begin and end marker around each run on its own stdout and stderr, and the extension keeps the bytes between them. Output that threads or subprocesses write between runs belongs to no run. Output written before the process dies is never lost, because the extension reads the process's streams directly.
At the deadline, the watchdog interrupts the main thread once with
KeyboardInterrupt, so an interruptible run ends and the session keeps its
state. The run gets no second interrupt, so its cleanup code can finish within
the grace period. Cancellation sends SIGINT to the same effect. When a run does not stop
within one second, the extension stops the session's process tree, and its
state is lost. A run that exits the process, for example with os._exit(), also
ends the session. sys.exit() ends only the run.
The model-visible result adds a line when a session starts, when it keeps its state after a timeout or cancellation, and when it loses its state. Details and the journal record the session name, the run's ordinal in the session, and whether the session was lost.
A run's PEP 723 dependencies are installed with uv pip install --target into
a package directory on the session's sys.path. Other PEP 723 settings, such
as requires-python, have no effect in a session. A package that the session
already imported keeps its loaded version. Pass reset: true to stop the
session and start it again before the run.
A session takes its working directory and environment from the run that starts it. Sessions end when Pi shuts down or reloads the extension. A session runs one run at a time; a second run while one is active fails as a setup error. Sessions need Python 3.11 or later.
Saved source references
The extension keeps the latest script visible to the model. It replaces older
completed scripts with {"sourceId": "<12 hex>"} after it has saved their
source, but only when the serialized reference arguments are at least 64 bytes
smaller than the serialized inline arguments (JSON escapes and UTF-8 bytes
included). Shorter scripts stay inline, because the model would otherwise have
to dereference an ID to save a few bytes. A source ID is the first 12 lowercase hex characters (48 bits) of the
source's SHA-256 digest, so it verifies itself: the digest of the loaded
source must start with the ID. The length is fixed and never grows. A save that
would put different source under an existing ID fails with
CodeArtifactCollisionError and never overwrites.
The model passes the sourceId to code_execution to rerun the exact verified
script, or to code_execution_source to read it without running it. The
reader supports offset and limit for long scripts and caps each source
chunk at 20 KiB. When the saved file is missing, the extension recovers the
source only from tool calls on the current session branch, never from another
branch. An unavailable source is a tool error for both tools.
Legacy structured sourceRef objects and path-bearing or XML-like placeholders
remain valid in resumed sessions. The context hook shows them as a structured
sourceRef, because a 16-hex legacy artifact name cannot be expressed as a
sourceId. A short ID that matches two different saved scripts is ambiguous
and fails; the full sourceRef still selects its exact script. Exactly one of code, sourceId, or sourceRef is accepted.
Saved output references
A run with nonempty output stores a canonical stdout-then-stderr transcript.
The transcript uses [stderr] as its channel boundary. Successful and expected
failure details include the saved artifact when it was stored:
type OutputArtifact = {
outputId: string; // 12 lowercase hex characters
sha256: string;
emittedBytes: number;
lines: number;
retainedBytes: number;
retainedLines: number;
truncated: boolean;
};
outputId is a commit-style short hash: the first 48 bits of a digest over the
artifact metadata. Its length is fixed. IDs already in session transcripts
cannot grow, so the extension never lengthens them to resolve ambiguity.
Instead, a save fails with OutputArtifactCollisionError when a different
artifact already owns the ID, and the existing artifact is never overwritten.
Each artifact is stored as <outputId>.out with a <outputId>.json sidecar
that holds its metadata. The sidecar is trusted only when its metadata hashes
to its own ID.
The extension retains at most 64 MiB per run. It continues to drain and count
output after that ceiling. The final result stays within 20 KiB and shows a
head-tail preview with exact emitted and omitted counts. It also states whether
all retained output is recoverable or whether the artifact itself was
truncated. When the preview omits output, the result text ends with an
outputId: <id> line. Pi sends result text, not details, to the model, so this
line is how the model gets the ID. Complete small outputs contain no ID.
Pass the outputId to code_execution_output. The reader accepts optional
UTF-8 byte offset and limit values, caps each page at 20 KiB, never splits a
UTF-8 character, and returns the next stable byte offset. It verifies the
sidecar, content digest, sizes, permissions, and regular-file boundary before
every read. The reader also accepts the legacy full outputRef object from
earlier sessions and verifies it the same way. IDs contain no paths.
Journal
The extension records every invocation of its three tools in a local SQLite
journal at $XDG_DATA_HOME/pi-code-execution/journal.sqlite. Set
PI_CODE_EXECUTION_JOURNAL to another path, or to off to record nothing. The
journal never changes a result and is never exported. See
docs/journal.md for the schema, outcomes, and queries.
Execution outcomes
Completed expected runs return structured details instead of throwing away process state:
type CodeExecutionFinalDetails = {
status:
| "success"
| "runtime_error"
| "setup_error"
| "timeout"
| "cancelled"
| "policy_error";
exitCode?: number;
signal?: string;
durationMs: number;
stdoutBytes: number;
stderrBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
sourceId: string;
output?: OutputArtifact;
nestedCalls: NestedToolCallRecord[];
// Set for a run in a named session.
session?: { name: string; ordinal: number; isLost: boolean };
};
runtime_error means CPython reached the user-code milestone and then failed.
setup_error covers failures before that milestone, including uv, dependency,
working-directory, and unavailable-source failures. The other statuses identify
deadlines, caller cancellation, and blocked nested calls directly.
While a script is running, partial updates use a smaller shape:
type CodeExecutionRunningDetails = {
status: "running";
sourceId: string;
};
Expected non-success results keep their details and retained output, then the
extension marks them as Pi tool errors. Invalid inputs, corrupt artifacts,
stream callback defects, and other internal extension errors still throw.
output is absent for empty output or when no artifact was persisted.
nestedCalls contains every attempted bridged call in start order, even if the
script later fails, reaches its deadline, or is cancelled.
Each nested record has stable parent and child IDs, the registered Pi name, the
Python callable name, an ISO start time, a monotonic duration, and a terminal
status. Input, result, and error values use 4 KiB UTF-8 previews with exact byte
counts and truncation flags. Compatible nested usage values stay on each
record and are summed once into the outer Pi tool result.
Trusted certificate authorities
Scripts run with Pi's environment, and TLS verification stays on. When Python
rejects a server certificate because no CA in its trust store issued it, the
result keeps the original error and adds a hint with the stable ID
certificate_verification.
uv-managed Python builds carry their own OpenSSL, whose default trust paths may not exist on your system. Give Python a trusted PEM CA bundle through the environment you start Pi with, and every run inherits it:
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt pi
Use your system bundle (for example /etc/pki/tls/certs/ca-bundle.crt on
Fedora), or a bundle that adds a corporate CA. requests reads
REQUESTS_CA_BUNDLE instead.
Two failures look alike and need different fixes:
- An environment trust problem: the script ran, then an HTTPS request
failed with
CERTIFICATE_VERIFY_FAILEDand thecertificate_verificationhint. Fix the trust store as above; the script is fine. When uv itself cannot verify the package index (invalid peer certificate: UnknownIssuer, hintuv_certificate), nothing was installed: setSSL_CERT_FILE, orUV_SYSTEM_CERTS=1(older uv:UV_NATIVE_TLS=1) to use the system trust store. - A dependency resolution failure: uv reached the index but could not
satisfy the PEP 723 dependencies (
No solution found, hintdependency_resolution). Fix the package names or version constraints.
The journal records which hint fired (runs.hint_ids) and the Python version
and executable of each run.
Tool bridge
Python can call every Pi tool that is active in the current session, except
the code_execution tools themselves. This includes Pi's built-in tools and
the tools of other extensions. No opt-in is necessary.
Pi has no public API to run another extension's tool. The extension finds the
registered tool definitions through Pi's internal ExtensionRunner, and
creates the built-in tools with Pi's public create*ToolDefinition
functions. A Pi upgrade can break the internal part.
Each nested call goes through the tool_call and tool_result hooks of the
loaded extensions, as a model's tool call does. A tool_call hook can patch
the input or block the call; a block ends the run as a policy error. A
tool_result hook can change the result, or mark it as an error. Python then
gets a RuntimeError with the result text. Thus a tool that returns its
failures instead of throwing, such as an MCP adapter tool, still raises in
Python.
An extension can also expose a tool explicitly through the shared
code_execution:collect_tools event, to attach nested lifecycle handlers. An
explicit registration replaces a discovered tool of the same name. The tool
must also be active in Pi.
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { CodeExecutionToolCollection } from "@jawfish/pi-code-execution/host";
import { issueSearchTool } from "./issue-search.ts";
export default function (pi: ExtensionAPI): void {
pi.registerTool(issueSearchTool);
pi.events.on("code_execution:collect_tools", (event) => {
(event as CodeExecutionToolCollection).add(issueSearchTool);
});
}
Python receives normalized callable names. available_tools() returns their
signatures:
print(available_tools())
result = await search_issues(query="startup failure")
print(result)
Calls are schema-validated in the Pi process. Bridged tool output is returned as text. If a Pi tool stored a larger truncated result, code execution can recover up to 5 MiB from that tool's output artifact.
The text is a str subclass with a json() method, so scripts parse JSON
output without an import:
projects = (await plane_project(action="list")).json()
The result type does not change with the content: a tool that returns an
error message instead of JSON still gives a str. json() then raises a
ValueError that names the tool and shows the start of the text. Each call
parses again and returns a new object.
MCP tools through pi-mcp-adapter can return text content followed by a
structuredContent: block with the same data. When the whole text is not
JSON, json() parses that block.
The model learns which tools are exposed without spending a run on
available_tools(). While code_execution is active, the extension adds a
hidden code_execution:exposed_tools status message before each prompt, and
after a turn that ran tools, when the exposed set differs from the last status
still in the model's context: either "no agent tools are exposed" or the
exposed signatures. An unchanged set adds nothing; a reload or resume reads the
last status from the session; a compaction that drops it causes one resend.
Nested lifecycle registration
collection.add(...definitions) remains the short form. Use
collection.register() when one integration owns policy or telemetry for a
tool:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type {
CodeExecutionToolCollection,
NestedToolCall,
NestedToolCallOutcome,
} from "@jawfish/pi-code-execution/host";
import { approveNestedCall, recordNestedCall } from "./policy.ts";
import { issueSearchTool } from "./issue-search.ts";
export default function (pi: ExtensionAPI): void {
pi.registerTool(issueSearchTool);
pi.events.on("code_execution:collect_tools", (event) => {
(event as CodeExecutionToolCollection).register(issueSearchTool, {
before: async (call: NestedToolCall) => {
if (!(await approveNestedCall(call))) {
throw new Error(`Approval denied for ${call.registeredName}`);
}
},
after: async (outcome: NestedToolCallOutcome) => {
await recordNestedCall(outcome);
},
});
});
}
A before handler can approve or block before the tool runs. Throwing blocks
the call with a catchable Python RuntimeError. The after handler runs once
for success, validation failure, block, tool failure, or cancellation. Both
handlers receive the outer ID, stable child ID, registered name, Python name,
start time, and the run's cancellation signal.
The lifecycle order is fixed:
- Emit the bounded
code_execution:nested_tool_startobservation. - Prepare and validate arguments against the registered Pi tool.
- Run the legacy
code_execution:tool_callinterception event. - Run the registration's async
beforehandler. - Use the registration's dispatcher, or call its definition directly. A
direct call runs the native Pi
tool_callhooks, the tool, and the nativetool_resulthooks. - Run the registration's async
afterhandler. - Emit the bounded
code_execution:nested_tool_finishobservation and attach the same terminal record to the outer result.
A validation failure skips steps 3 through 5. A legacy block skips steps 4 and
5. The after handler and finish observation still run. Name-based checks must
use registeredName or the legacy toolName alias. pythonName is only the
normalized launcher lookup name.
The start and finish observations let another extension audit calls without owning the registration. They expose 4 KiB previews rather than full inputs, results, or errors:
pi.events.on("code_execution:nested_tool_start", (record) => {
console.log("nested start", record);
});
pi.events.on("code_execution:nested_tool_finish", (record) => {
console.log("nested finish", record);
});
The optional registration dispatch function is the replaceable dispatch
boundary. Pi 0.84 has no public API that invokes another tool through its normal
validation, tool_call, approval, tool_result, and telemetry path. The
extension emits the native hooks through Pi's internal extension runner. A
custom dispatch that does not call the definition skips them. A future
public Pi dispatcher can implement dispatch without changing Python
callables or the outer nestedCalls contract.
The legacy code_execution:tool_call event remains available. Its mutable event
contains the stable identities, registered toolName, prepared input, cwd,
signal, and optional block and reason fields.
Security model
code_execution is not a sandbox. Generated Python runs with the same user
permissions as Pi and inherits its environment. It can read and write files,
start subprocesses, access the network, and install packages. Only install this
extension and PEP 723 dependencies from sources you trust.
Bridged tools can also have side effects. Cancellation stops waiting for a bridged call, but it cannot undo effects that already happened.
Tool dispatch uses an authenticated per-run loopback connection. This prevents an unrelated local process from accidentally calling the bridge, but it is not a boundary against the Python process itself.
Cancellation uses a POSIX process group and escalates from SIGTERM to
SIGKILL. A session run is first interrupted, and its process group is
stopped the same way only when the run does not stop. A session's control
connection is authenticated like tool dispatch. Deliberately detached descendants can escape that group. Windows is
not supported because Node does not provide equivalent process-tree
containment without an additional native job-object implementation.
The launcher is written to $XDG_CACHE_HOME/pi-code-execution/launchers/
(default ~/.cache/...), named by a hash of its content. uv keys a script's
environment on its path, so runs with the same PEP 723 block share one uv
environment instead of adding one to uv's cache for each run.
Older sources are stored under Pi's agent directory in code-execution/.
Retained transcripts are stored in code-execution-output/. Directories and
files use private POSIX modes where supported. Artifacts remain until the user
removes them, and any process running as the same user may read them. Session
references contain artifact IDs, not machine-specific absolute paths.
Development
bun install
bun run test
bun run typecheck
PI_SKIP_VERSION_CHECK=1 pi --no-extensions -e . --list-models
The test suite requires uv and a platform with POSIX process groups.
License
MIT