No description
  • TypeScript 100%
Find a file
James Fitzgerald 7637a1917d
Some checks are pending
CI / verify (push) Has started running
handle root permissions and zombies in ci
2026-09-26 12:49:57 -05:00
.github/workflows ci: pin bun 1.4.2 for node:sqlite support 2026-09-25 22:31:54 -05:00
docs print the value of a final expression 2026-09-25 23:37:57 -05:00
.gitignore add domain glossary and journal ADR 2026-09-25 20:37:54 -05:00
AGENTS.md add AGENTS.md 2026-09-25 20:01:49 -05:00
artifacts.test.ts replay and inspect saved source by a short source ID 2026-09-25 20:56:08 -05:00
artifacts.ts handle root permissions and zombies in ci 2026-09-26 12:49:57 -05:00
bun.lock show outputRef in model-visible truncated results 2026-09-25 20:09:12 -05:00
CONTEXT.md keep Python state across calls in named sessions 2026-09-25 23:33:33 -05:00
context.test.ts record every context transform in the journal 2026-09-25 21:07:50 -05:00
context.ts fix context transform and assistant message review findings 2026-09-25 21:16:10 -05:00
core.ts replace outputRef objects with short output IDs 2026-09-25 20:41:02 -05:00
diagnostics.test.ts parse JSON tool output with result.json() 2026-09-25 23:09:42 -05:00
diagnostics.ts parse JSON tool output with result.json() 2026-09-25 23:09:42 -05:00
host.test.ts record the run timeline and run report 2026-09-25 21:30:09 -05:00
host.ts expose all active Pi tools to Python 2026-09-25 22:46:07 -05:00
index.test.ts print the value of a final expression 2026-09-25 23:37:57 -05:00
index.ts fix dogfood findings in results and sessions 2026-09-26 11:23:19 -05:00
journal.test.ts harden the journal after review 2026-09-25 21:11:59 -05:00
journal.ts keep Python state across calls in named sessions 2026-09-25 23:33:33 -05:00
launcher.ts fix dogfood findings in results and sessions 2026-09-26 11:23:19 -05:00
LICENSE initial release 2026-08-26 16:00:04 -05:00
output-artifacts.test.ts store source, output, and reference resolution in the journal 2026-09-25 21:39:20 -05:00
output-artifacts.ts store source, output, and reference resolution in the journal 2026-09-25 21:39:20 -05:00
package.json expose all active Pi tools to Python 2026-09-25 22:46:07 -05:00
README.md fix dogfood findings in results and sessions 2026-09-26 11:23:19 -05:00
registered-tools.ts run extension tool hooks on nested calls 2026-09-25 23:02:33 -05:00
rendering.test.ts keep Python state across calls in named sessions 2026-09-25 23:33:33 -05:00
rendering.ts keep Python state across calls in named sessions 2026-09-25 23:33:33 -05:00
runner.test.ts handle root permissions and zombies in ci 2026-09-26 12:49:57 -05:00
runner.ts fix dogfood findings in results and sessions 2026-09-26 11:23:19 -05:00
tsconfig.json initial release 2026-08-26 16:00:04 -05:00

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 sourceId in 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_FAILED and the certificate_verification hint. Fix the trust store as above; the script is fine. When uv itself cannot verify the package index (invalid peer certificate: UnknownIssuer, hint uv_certificate), nothing was installed: set SSL_CERT_FILE, or UV_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, hint dependency_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:

  1. Emit the bounded code_execution:nested_tool_start observation.
  2. Prepare and validate arguments against the registered Pi tool.
  3. Run the legacy code_execution:tool_call interception event.
  4. Run the registration's async before handler.
  5. Use the registration's dispatcher, or call its definition directly. A direct call runs the native Pi tool_call hooks, the tool, and the native tool_result hooks.
  6. Run the registration's async after handler.
  7. Emit the bounded code_execution:nested_tool_finish observation 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