pi-repl-py 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ # Architecture
2
+
3
+ Two processes: the host lives inside pi, the guest owns the Python workspace.
4
+
5
+ ```
6
+ pi
7
+ └─ extension (index.ts) registers `execute`, dormant until --repl
8
+ └─ EngineManager (src/engine/index.ts) spawn host: snapshots, teardown
9
+ │ stdin ──▶ protocol commands (run / snapshot / restore / ping)
10
+ │ fd 3 ◀── stream, done, snapshot_result, ...
11
+ └─ guest.py ▶ jupyter_client ▶ a real ipython kernel (subprocess)
12
+ ```
13
+
14
+ The host is TypeScript; the evaluator is Python in its own process. Splitting
15
+ them is what makes a bad cell survivable: a cell can raise, leak memory, or
16
+ wedge the guest without taking pi down, and the host, being not the thing
17
+ that failed, always gets to report what happened.
18
+
19
+ ## The Python environment (the venv)
20
+
21
+ The evaluator is a real `ipython` kernel, so it needs a Python environment with
22
+ `ipykernel` + `jupyter_client`. You cannot fake that with a script; it is a
23
+ hard runtime dependency.
24
+
25
+ When installed as a pi package, `npm install` runs `postinstall`
26
+ (`scripts/setup-venv.mjs`), which creates a stable per-user venv:
27
+
28
+ ```
29
+ ~/.pi/agent/pi-repl-venv/bin/python3
30
+ ```
31
+
32
+ That path is stable across updates because it sits outside the ephemeral
33
+ package dir under `~/.pi/agent/npm`. If `python3` or the network is missing at
34
+ install time, postinstall prints a clear notice and the host falls back at
35
+ runtime.
36
+
37
+ At spawn, `resolvePythonPath` chooses the interpreter in order:
38
+
39
+ 1. the repo's own `.venv` (development)
40
+ 2. a cwd-local `.venv` (project)
41
+ 3. `~/.pi/agent/pi-repl-venv` (package install)
42
+ 4. `$PYTHON` or `python3`
43
+
44
+ The first existing one wins. The model is told (via `help()`) that it runs in a
45
+ project-local venv, not the system interpreter, so it does not leak the wrong
46
+ assumption into commands.
47
+
48
+ ## The guest
49
+
50
+ `src/engine/guest.py` uses `jupyter_client.KernelManager` to start a real
51
+ `ipykernel` subprocess (`python -m ipykernel`), keeps a blocking client
52
+ attached, and stays alive for the whole session. Cells run in that kernel via
53
+ `kc.execute(code)`, so state persists because the kernel process does.
54
+
55
+ The wire protocol rides two channels, both load-bearing:
56
+
57
+ *Separation.* Protocol traffic uses a dedicated pipe (fd 3). The guest's real
58
+ stdout/stderr carry only user output, so a cell printing JSON cannot be parsed
59
+ as a protocol message.
60
+
61
+ *Authentication.* Every frame carries a nonce the host mints at spawn and the
62
+ guest erases from its environment before any cell runs. Code inside a cell
63
+ cannot recover it. Without this, a cell could announce its own completion and
64
+ claim success while failing — an agent that cannot trust its own results has
65
+ nothing.
66
+
67
+ ## Toolbox loading
68
+
69
+ At boot the guest and the host both read the toolbox directory (default
70
+ `src/engine/toolbox`, overridden by config `toolboxDir` → env `PI_TOOLBOX_DIR`).
71
+
72
+ - **guest** execs each `*.py` into the kernel namespace, making functions
73
+ callable.
74
+ - **host** reads the same files to build the functions list on the `execute`
75
+ tool's `promptGuidelines` (and the tool `description`), so the model sees the
76
+ real signatures and one-line summaries.
77
+
78
+ The loader reads each file's `def (...)`: signature (authoritative) and its
79
+ `function_description = """..."""` (one-line summary, optional). Since both
80
+ sides read the same directory, a function in the prompt also exists in the
81
+ kernel. A file renamed with a `_` prefix is skipped by both, so a disabled
82
+ function is never advertised where it does not load. See
83
+ `docs/how-to-functions.md`.
84
+
85
+ The `promptGuidelines` are built once, when the `execute` tool is registered
86
+ (module load). A toolbox change therefore needs a **session restart / `/reload`**
87
+ to be reflected in the prompt — the kernel also loads the toolbox only at boot.
88
+
89
+ `ls()` and `help(name)` are built into the kernel (not toolbox files), so a
90
+ bare kernel still lets the model discover what is loaded.
91
+
92
+ ## Snapshots & honest resets
93
+
94
+ After each successful cell the host schedules a debounced snapshot: it asks
95
+ the guest to pickle the kernel's globals (entry-by-entry so one bad value
96
+ costs only itself), and stores that as `namespace.snapshot` keyed to the
97
+ session file. On a fresh engine it restores, and whatever cannot be pickled
98
+ (live handles, some objects) is reported by name.
99
+
100
+ If the evaluator restarts, the result is prefixed with a `<rlm_engine_reset>`
101
+ block naming what was revived and what was lost, so the model re-verifies
102
+ before reuse rather than trusting state that is gone.
103
+
104
+ ## Failure modes
105
+
106
+ | Failure | Behaviour |
107
+ | --- | --- |
108
+ | Cell throws | `done { status: "error" }` with traceback; kernel namespace intact |
109
+ | Kernel wedged | timeout → kill kernel subprocess → spawn fresh → restore snapshot |
110
+ | Guest process dies | pending calls settle; engine reports itself down; later calls reject |
111
+ | Host exits | guest is killed; on abrupt death it self-exits on stdin EOF |
112
+ | Output flood | capped per channel, truncation announced |
113
+
114
+ ## Testing
115
+
116
+ - **Host (bun):** `test/units.test.ts` (protocol, render, config) +
117
+ `test/preview-core.test.ts`.
118
+ - **Evaluator (pytest):** `test/guest_contract.py` drives a real guest and
119
+ asserts persistence, error-survival, output attribution, snapshots, ls/help.
120
+ - **Integration (slow):** `test/engine.integration.test.ts` boots a real
121
+ engine + guest and proves a variable survives an engine restart.
122
+
123
+ Gate: `just check` = biome + bun test (host) + pytest (guest).
124
+ `just integration` adds the real-host seam.
125
+
126
+ ## Configuration reference
127
+
128
+ Loaded from `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`), first-found-wins, never
129
+ throws on a missing/malformed file.
130
+
131
+ | Key | Type / default | Meaning |
132
+ | --- | --- | --- |
133
+ | `toolboxDir` | string, optional | Directory of one-function-per-`.py` files that replaces the shipped `src/engine/toolbox`. `~` is expanded; a bare relative path resolves from the process cwd (not reliable) — prefer an absolute path. |
134
+ | `pythonPath` | string, optional | The interpreter used to spawn the guest. Omit to use `resolvePythonPath` (see venv). |
135
+ | `timeoutMs` | number, 60000 | Per-cell execution timeout in ms. |
136
+ | `snapshotDebounceMs` | number, 1500 | Debounce after an ok cell before snapshot, in ms. |
137
+
138
+ ## Reference documentation
139
+
140
+ - Philosophy and design rationale: [docs/philosophy.md](docs/philosophy.md)
141
+ - Adding a toolbox function: [docs/how-to-functions.md](docs/how-to-functions.md)
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shift Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # pi-repl
2
+
3
+ A [pi](https://pi.dev) extension that gives the agent a single `execute` tool backed by a
4
+ **persistent Python evaluator**: a real `ipython` kernel that keeps variables, functions, imports,
5
+ and data alive across every call and turn.
6
+
7
+ There is no interactive shell. The agent batches code into a Python workspace that lives for the
8
+ whole session, and only the printed result comes back. That is the part of a REPL an agent wants:
9
+ lasting state and code-as-a-workspace, without the interactive loop in the way.
10
+
11
+ ```
12
+ ✓ repl · data = load_json("records.json") · done
13
+ ✓ repl · avg = sum(v["score"] for v in data)/len(data)
14
+ ✓ repl · print("mean score:", round(avg, 2)) · mean score: 41.7
15
+ ```
16
+
17
+ `data` is still there in cell three. Nothing was re-read, nothing re-derived from output, because
18
+ the kernel stayed alive.
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ # from a clone, one-time setup
24
+ just setup # npm install + a project-local .venv with the guest deps
25
+
26
+ # run a session
27
+ pi --repl
28
+ ```
29
+
30
+ A plain `pi` session is untouched; the extension is dormant until `--repl` is passed (or
31
+ `PI_REPL_FORCE=1`).
32
+
33
+ ## Installing as a pi package
34
+
35
+ `npm install` runs a `postinstall` that creates the Python venv the evaluator needs, at a stable
36
+ per-user path (`~/.pi/agent/pi-repl-venv`). If `python3` or the network is missing, it prints a
37
+ clear notice. How the interpreter is resolved is in [docs/philosophy.md](docs/philosophy.md).
38
+
39
+ ## What you get
40
+
41
+ - **A persistent namespace.** Variables, functions, imports, and data survive across cells and
42
+ turns; snapshots preserve them across a best-effort restart.
43
+ - **A real `ipython` kernel**, not a hand-rolled `exec` loop.
44
+ - **Shell as values.** `bash("git log --oneline")` returns a `CompletedProcess` you read
45
+ `.stdout`/`.stderr`/`.returncode` on.
46
+ - **Error survival.** A cell that throws reports the traceback and the kernel keeps going.
47
+ - **An honest evaluator.** If it restarts, it names what state it could revive and what it lost, so you don't trust memory that's gone.
48
+
49
+ ## The toolbox
50
+
51
+ A small set of Python functions is preloaded into every kernel and surfaced to the
52
+ model through the `execute` tool's prompt guidance (their signatures + one-line
53
+ summaries are listed there, and `ls()`/`help()` discover them at runtime), so the
54
+ model can call `read`, `write`, `edit`, and `bash` without reimplementing them.
55
+ Set `toolboxDir` to point at your own folder.
56
+
57
+ The function list shown to the model is built when the `execute` tool is
58
+ registered, so changing the toolbox (adding/removing a file, renaming one with a
59
+ `_` prefix) needs a **session restart / `/reload`** for the prompt to reflect it —
60
+ the kernel also only loads the toolbox at boot.
61
+
62
+ - Adding a function (the file contract, docstrings, disabling): [docs/how-to-functions.md](docs/how-to-functions.md)
63
+
64
+ ## Configuration
65
+
66
+ `~/.pi/agent/pi-repl.json` (or `$PI_REPL_CONFIG`) sets `toolboxDir`, `pythonPath`, and timeouts.
67
+ Full keys and path rules: [ARCHITECTURE.md](ARCHITECTURE.md).
68
+
69
+ ## More
70
+
71
+ - Why this design: [docs/philosophy.md](docs/philosophy.md)
72
+ - How it works, the venv, config reference: [ARCHITECTURE.md](ARCHITECTURE.md)
73
+
74
+ ## It is not
75
+
76
+ - A sandbox. The kernel runs with your permissions; the toolbox trusts you.
77
+ - A subagent framework. There are no `rlm.run` subagents; spawn a process with `bash()`.
78
+ - A pi tool-rack. It is one `execute` tool with functions inside.
79
+
80
+ ## License
81
+
82
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,107 @@
1
+ # How to add a toolbox function
2
+
3
+ A toolbox function is one `.py` file that pi-repl loads into every kernel and
4
+ surfaces to the model through the `execute` tool's prompt guidance (its
5
+ signature + one-line summary appears in `promptGuidelines`). Add a file, and it
6
+ shows up wherever the toolbox is read.
7
+
8
+ > **When a change shows up.** The kernel loads the toolbox at boot, and the
9
+ > `execute` tool builds its function list at registration (module load), so a
10
+ > toolbox change (add/remove a file, rename one with a `_` prefix) is picked up
11
+ > by a **session restart / `/reload`** — not mid-session.
12
+
13
+ ## Where functions live
14
+
15
+ By default the extension ships four (`read`, `write`, `edit`, `bash`) in
16
+ `src/engine/toolbox/`. To use your **own** set, set `toolboxDir` in your
17
+ config:
18
+
19
+ ```jsonc
20
+ // ~/.pi/agent/pi-repl.json
21
+ { "toolboxDir": "~/.pi/agent/pi-repl-functions" }
22
+ ```
23
+
24
+ Use an absolute path or a `~`-prefixed one (`~` expands to your home). A bare relative
25
+ path resolves from the process working directory, which is not reliable, so prefer
26
+ an absolute path for a stable per-user folder. Point `toolboxDir` at a directory and
27
+ every `*.py` there is loaded. Note: it **replaces** the shipped defaults; you do not
28
+ get built-ins plus yours, unless you copy the built-ins into your folder too.
29
+
30
+ ## The file contract
31
+
32
+ Every toolbox file must:
33
+
34
+ 1. have a `def` whose signature is the real call an agent would use, and
35
+ 2. may declare `function_description` (a short one-line summary shown in the
36
+ prompt).
37
+
38
+ A minimal, valid file:
39
+
40
+ ```python
41
+ # pi-repl-functions/summarize.py
42
+ function_description = """Return a first-sentence summary of a text."""
43
+
44
+ __all__ = ["summarize"]
45
+
46
+ def summarize(text, limit=1):
47
+ return ". ".join(text.split(". ")[:limit]) + "."
48
+ ```
49
+
50
+ That is everything. `summarize` loads into the kernel and the `execute` tool's
51
+ prompt guidance shows `summarize(text, limit=1)` after the next session restart.
52
+
53
+ ## The two pieces the loader reads
54
+
55
+ **1. The signature comes from the `def`, not the description.**
56
+ Arguments are read from the actual `def` line rather than hand-copied into a
57
+ docstring, so the signature the model sees tracks the code for a normal
58
+ single-line signature. Change `def summarize(text,
59
+ limit=1):` to `limit=200`, and after the next session restart the prompt
60
+ updates to match.
61
+
62
+ **2. The description, from `function_description`, optional.**
63
+ Used as the one-line summary in the `execute` tool's prompt guidance. If you
64
+ omit it, the function is still advertised (by signature), just without a
65
+ one-liner.
66
+
67
+ Each file should also give the function a real docstring (the text under
68
+ `def`). That docstring is shown by `help(name)` in the kernel and carries the
69
+ deeper usage and gotchas. It does not go into the execute tool's prompt
70
+ guidance (only the one-line `function_description` does). Keep it for
71
+ details, the venv note, and edge cases.
72
+
73
+ ## How much to document
74
+
75
+ `function_description` is the summary; the `def` docstring is the detail. A
76
+ good `function_description` is one line ("Run a shell command and return its
77
+ result."). A good docstring explains arguments, return value, and any
78
+ non-obvious behavior, including environment facts the model needs
79
+ ("the evaluator runs in a project-local venv, not the system python").
80
+
81
+ ## Disabling a file without deleting it
82
+
83
+ Rename the file to start with an underscore: `_test_helper.py`. The loader
84
+ **(and the execute tool's prompt guidance)** skip underscore-prefixed files, so
85
+ it never reaches the kernel or the model. Use this for scratch or internal
86
+ helpers.
87
+
88
+ ## Good practice
89
+
90
+ - One function per file, name matches the function.
91
+ - Keep `function_description` one line. Everything else goes in the docstring.
92
+ - Let the signature carry the truth; the description says what it's *for*.
93
+ - A function that can hang (a shell call, network) should say so in its
94
+ docstring so the model knows the trade-off.
95
+
96
+ ## Confirming it worked
97
+
98
+ At a `pi --repl` prompt, run a cell:
99
+
100
+ ```python
101
+ print(ls()) # list what's loaded
102
+ print(help('summarize')) # signature + full docstring details
103
+ ```
104
+
105
+ If `summarize` shows up in `ls()` and `help`, it loaded. The `execute` tool's
106
+ prompt guidance also lists it (same first-line summary) after the next session
107
+ restart.
@@ -0,0 +1,88 @@
1
+ # Philosophy: why a persistent Python workspace
2
+
3
+ ## The bet
4
+
5
+ Most coding agents are a pile of point tools. A read tool, a bash tool, an edit
6
+ tool, a find tool, each with its own schema, its own failure modes, its own
7
+ token cost to describe. The model spends context deciding *which* tool, then
8
+ *how* the output should be threaded into the next one.
9
+
10
+ pi-repl makes the opposite bet: **give the model one persistent Python
11
+ workspace and let it write the composition itself.** Configuration, state, and
12
+ file access all happen in code, in one living namespace. The model's interface
13
+ to the world never grows — the *code* it writes adapts instead.
14
+
15
+ That is a "REPL" the way an agent actually wants one. Not an interactive
16
+ lozenge to type into, but a long-lived working memory the model owns.
17
+
18
+ ## What persistence buys
19
+
20
+ A separate-tool loop re-parses text every step. `read` returns a string, the
21
+ agent pastes it, `grep` returns lines, the agent re-reads them. Every
22
+ transformation is round-tripped through the transcript and billed as tokens.
23
+
24
+ In a persistent kernel, that work happens once and stays put:
25
+
26
+ - a variable assigned in one cell is there in the next, and the next turn;
27
+ - a function defined once is reusable for the whole session;
28
+ - `bash()` returns a real `subprocess.CompletedProcess`, not a transcript
29
+ snippet, so the agent branches on `.returncode` and slices `.stdout` with
30
+ normal code.
31
+
32
+ The savings compound harder for small models. Holding a whole file in context
33
+ to avoid re-reading it is expensive precisely when context is scarce; the
34
+ kernel lets the model aggregate, filter, and store in code, printing only what
35
+ the current step needs.
36
+
37
+ ## Why a real kernel
38
+
39
+ pi-repl does not hand-roll an `exec` loop. It drives a genuine `ipython`
40
+ kernel in a subprocess via `jupyter_client`. That buys:
41
+
42
+ - rich, real tracebacks instead of a wrapped `except`;
43
+ - the full standard library and real `import` semantics;
44
+ - last-expression capture;
45
+ - a namespace that genuinely survives errors, instead of a script string passed
46
+ to `exec`.
47
+
48
+ And it is an honest isolation boundary: the kernel is a separate process from
49
+ pi. A cell can raise, or consume memory, or spin, and pi keeps answering because
50
+ pi is not the process that failed. The host restores from the last completed
51
+ snapshot and tells the model exactly what came back in a `<rlm_engine_reset>`
52
+ notice. More in `ARCHITECTURE.md`.
53
+
54
+ ## The venv as part of the design
55
+
56
+ Because the evaluator is real Python, it needs a real Python environment with
57
+ `ipykernel` + `jupyter_client`. You cannot conjure that from nothing.
58
+
59
+ The package's `postinstall` creates it once, at a stable user path
60
+ (`~/.pi/agent/pi-repl-venv`), so a `pi install` ends with a working evaluator
61
+ and updates do not lose it (the venv is outside the ephemeral package dir
62
+ where it would vanish). At runtime the host resolves the interpreter in a
63
+ short deterministic order (repo venv, cwd venv, the install venv, then
64
+ `$PYTHON`/`python3`). The system interpreter is the fallback, never the
65
+ assumption, because the whole tool quietly breaks if it silently runs in the
66
+ wrong environment. This is a fact the toolbox functions' test in `help()`
67
+ exist to keep visible.
68
+
69
+ ## Trust, not a sandbox
70
+
71
+ This is deliberately **not** a sandbox. The kernel runs with your user's
72
+ permissions, can read and write anywhere you can, and the toolbox is trusted
73
+ as written. If you need to guard against an untrusted model, this is the wrong
74
+ tool: reach for a real sandbox the way you would for any untrusted user code.
75
+ The philosophy prefers a sharp, honest tool over a pretend-safe one.
76
+
77
+ ## What it isn't
78
+
79
+ - A subagent framework. There is no `rlm.run`. To delegate, the model spawns
80
+ a process with `bash()`.
81
+ - A drop-in pi-tool parcel. It exposes one `execute` tool; everything else is
82
+ inside that workspace.
83
+ - A replacement for your own editing/browsing tools required. It is there
84
+ when the working style above is worth it, dormant otherwise.
85
+
86
+ The trade-off is real and accepted: the agent pays a little more per-call to
87
+ hold a heavier environment, and it gets back far fewer re-reads, fewer
88
+ transcript round-trips, and sharper small-model behavior.
package/index.ts ADDED
@@ -0,0 +1,220 @@
1
+ // --- pi-repl: one execute tool over Python; everything else runs as functions inside it ---
2
+
3
+ import { basename, join } from "node:path";
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import { Type } from "typebox";
6
+ import { EngineBusyError, EngineManager } from "./src/engine/index.js";
7
+ import { loadConfig } from "./src/extension/config.js";
8
+ import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
9
+ import { EngineLifecycle, summarizeNames } from "./src/extension/session-engine.js";
10
+ import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
11
+
12
+ const executeSchema = Type.Object({
13
+ code: Type.String({
14
+ description: "Python to execute in the persistent evaluator.",
15
+ }),
16
+ });
17
+
18
+ function syncRenderState(
19
+ state: Partial<ExecuteRenderState>,
20
+ context: {
21
+ args?: { code?: string };
22
+ isPartial: boolean;
23
+ isError: boolean;
24
+ expanded: boolean;
25
+ executionStarted: boolean;
26
+ },
27
+ ): ExecuteRenderState {
28
+ state.code = context.args?.code ?? state.code ?? "";
29
+ state.isPartial = context.isPartial;
30
+ state.isError = context.isError;
31
+ state.expanded = context.expanded;
32
+ state.executionStarted = context.executionStarted;
33
+ state.hasResult = state.hasResult ?? false;
34
+ return state as ExecuteRenderState;
35
+ }
36
+
37
+ /** Stack lines kept when surfacing a cell error to the model. */
38
+ const ERROR_STACK_LINES = 10;
39
+
40
+ /** Header plus stack — without repeating the header when the stack already starts with it. */
41
+ function composeErrorLines(error: { name: string; message: string; stack: string[] }): string[] {
42
+ const header = `${error.name}: ${error.message}`;
43
+ const stack = error.stack.slice(0, ERROR_STACK_LINES);
44
+ return stack[0]?.trim() === header ? stack : [header, ...stack];
45
+ }
46
+
47
+ const CFG = loadConfig();
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ pi.registerFlag("repl", {
51
+ type: "boolean",
52
+ description: "Single execute tool backed by a persistent Python evaluator; replaces the default tool surface",
53
+ });
54
+ // Flag only lands post-factory; gate per event. PI_REPL_FORCE is the dev escape.
55
+ const active = () => pi.getFlag("repl") === true || process.env.PI_REPL_FORCE === "1";
56
+
57
+ let location = { cwd: process.cwd(), sessionFile: undefined as string | undefined };
58
+ // Pi rebuilds thrown errors as bare text; stash details to re-attach in tool_result.
59
+ const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
60
+
61
+ const lifecycle = new EngineLifecycle<EngineManager>({
62
+ create() {
63
+ const { cwd, sessionFile } = location;
64
+ const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
65
+ const stateDir = join(cwd, ".pi-repl", sessionKey ?? "ephemeral");
66
+ return new EngineManager({
67
+ cwd,
68
+ pythonPath: CFG.pythonPath,
69
+ timeoutMs: CFG.timeoutMs,
70
+ toolboxDir: CFG.toolboxDir,
71
+ // --- snapshots are keyed to a session file; ephemeral sessions get none ---
72
+ snapshot: sessionKey ? { path: join(stateDir, "namespace.snapshot") } : undefined,
73
+ });
74
+ },
75
+ async dispose(engine) {
76
+ await engine.dispose();
77
+ },
78
+ // --- a wedged guest can't answer dispose; kill and rely on the last snapshot ---
79
+ async discard(engine) {
80
+ await engine.kill();
81
+ },
82
+ });
83
+
84
+ // --- no custom prompt: pi's default prompt stands. session_start collapses
85
+ // the active set to just `execute`, so the default prompt's built-in
86
+ // read/bash/edit/write never appear. All REPL knowledge (description,
87
+ // promptSnippet, promptGuidelines) lives on the tool itself, not in a
88
+ // prompt builder. ---
89
+
90
+ pi.on("session_start", async (_event, ctx) => {
91
+ if (!active()) {
92
+ // --- drop execute so a stock session stays stock ---
93
+ pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "execute"));
94
+ return;
95
+ }
96
+ // --- active: the whole surface collapses to the one tool ---
97
+ pi.setActiveTools(["execute"]);
98
+ // --- revive the previous run; the engine also self-revives if session_start was skipped ---
99
+ location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
100
+ const { restore } = await lifecycle.acquire("startup");
101
+ if (restore && restore.restored.length > 0) {
102
+ pi.sendMessage({
103
+ customType: "pi-repl-restore",
104
+ content: `Revived ${restore.restored.length} variable(s) from the previous run: ${summarizeNames(restore.restored, 8)}${
105
+ restore.failed.length > 0
106
+ ? `. Failed: ${summarizeNames(
107
+ restore.failed.map((f) => f.name),
108
+ 8,
109
+ )}`
110
+ : ""
111
+ }`,
112
+ display: true,
113
+ });
114
+ }
115
+ });
116
+
117
+ pi.on("session_shutdown", async () => {
118
+ await lifecycle.shutdown();
119
+ });
120
+
121
+ pi.on("tool_result", async (event) => {
122
+ if (event.toolName !== "execute") return undefined;
123
+ const stashed = pendingErrorResults.get(event.toolCallId);
124
+ pendingErrorResults.delete(event.toolCallId);
125
+ if (!stashed || !event.isError) return undefined;
126
+ // --- restore the collapsed details an errored cell lost ---
127
+ return { content: event.content, details: stashed.details, isError: true };
128
+ });
129
+
130
+ pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
131
+ name: "execute",
132
+ label: "execute",
133
+ description: EXECUTE_DESCRIPTION,
134
+ promptSnippet: EXECUTE_PROMPT_SNIPPET,
135
+ promptGuidelines: buildExecutePromptGuidelines(CFG.toolboxDir),
136
+ parameters: executeSchema,
137
+ renderShell: "self",
138
+ renderCall(args, theme, context) {
139
+ const state = syncRenderState(context.state, { ...context, args });
140
+ // --- compact header lives in the call slot ---
141
+ return new ExecuteCellComponent(state, theme, "header");
142
+ },
143
+ renderResult(result, options, _theme, context) {
144
+ const state = syncRenderState(context.state, context);
145
+ state.hasResult = true;
146
+ state.isPartial = options.isPartial;
147
+ state.expanded = options.expanded;
148
+ state.details = (result.details as ExecuteDetails | undefined) ?? state.details;
149
+ state.contentText = result.content
150
+ ?.filter((block): block is { type: "text"; text: string } => block.type === "text")
151
+ .map((block) => block.text)
152
+ .join("\n");
153
+ // --- body (code + output) lives in the result slot; Ctrl+O expands it ---
154
+ return new ExecuteCellComponent(state, _theme, "body");
155
+ },
156
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
157
+ if (!active()) {
158
+ throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
159
+ }
160
+ if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
161
+ // --- previous engine died mid-session; acquire revives it ---
162
+ const { engine: m } = await lifecycle.acquire("cell");
163
+ try {
164
+ // --- accumulate partial updates so the row height doesn't oscillate ---
165
+ let streamed = "";
166
+ const r = await m.execute(params.code, {
167
+ signal,
168
+ onStream: (chunk) => {
169
+ streamed += chunk;
170
+ onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
171
+ },
172
+ });
173
+ // --- reset notice leads so the model reads that its namespace was rebuilt ---
174
+ const sections = [lifecycle.takeResetNotice(), r.stdout, r.stderr, r.result];
175
+ const errorLines = r.error ? composeErrorLines(r.error) : undefined;
176
+ if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
177
+ if (r.status === "aborted") sections.push("[cell aborted]");
178
+ const text = sections.filter((section) => section !== undefined && section !== "").join("\n");
179
+
180
+ const details: ExecuteDetails = {
181
+ status: r.status,
182
+ durationMs: r.durationMs,
183
+ errorName: r.error?.name,
184
+ stdout: r.stdout || undefined,
185
+ stderr: r.stderr || undefined,
186
+ result: r.result,
187
+ errorStack: errorLines,
188
+ };
189
+ const result = { content: [{ type: "text" as const, text: text || "(no output)" }], details };
190
+ if (r.status === "error") {
191
+ pendingErrorResults.set(toolCallId, { details });
192
+ throw new Error(text || "(no output)");
193
+ }
194
+ if (r.status === "aborted") {
195
+ // A cancelled cell's kernel may still be executing work the guest
196
+ // single-threaded loop can't interrupt. Discard the engine so the
197
+ // NEXT run gets a fresh kernel instead of queuing behind the
198
+ // still-busy one (same class as the stalled-timeout recovery).
199
+ await lifecycle.discard();
200
+ }
201
+ return result;
202
+ } catch (error) {
203
+ if (error instanceof EngineBusyError) {
204
+ // --- discard the wedged engine; the next cell revives from the last snapshot ---
205
+ await lifecycle.discard();
206
+ throw new Error(
207
+ "The evaluator was wedged by a previously interrupted cell and has been killed. " +
208
+ "Run the next cell to get a fresh evaluator revived from the last snapshot; " +
209
+ "anything newer than that snapshot is gone, so re-verify variables before reusing them.",
210
+ );
211
+ }
212
+ // --- a guest that died leaves the engine shutdown; drop it so the next cell rebuilds fresh ---
213
+ if (m.isRunning === false) {
214
+ await lifecycle.discard();
215
+ }
216
+ throw error;
217
+ }
218
+ },
219
+ });
220
+ }