pi-repl-py 0.7.1 → 0.8.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.
@@ -1,39 +1,49 @@
1
1
  # Architecture
2
2
 
3
- pi-repl runs in **two processes**: pi hosts the TypeScript extension, which manages a separate
4
- Python `ipykernel` process where user code runs, speaking the standard Jupyter protocol directly
5
- (no Python middleman, no private framing). A cell can raise or wedge the kernel without taking pi
6
- down; the host stays answerable.
3
+ pi-repl runs in **three processes**: pi hosts the TypeScript extension, which manages a small
4
+ Python bridge (`bridge.py`) that owns the real `ipykernel` evaluator through `jupyter_client`.
5
+ The host and the bridge speak one tiny JSON-lines vocabulary over a stdio pipe; the bridge speaks
6
+ the standard Jupyter protocol to the kernel with ready-made libraries. A cell can raise or wedge
7
+ the kernel without taking pi down; the host stays answerable.
7
8
 
8
9
  ```
9
10
  pi
10
11
  └─ extension (index.ts) registers `execute`; dormant until --repl
11
12
  └─ EngineManager (src/engine/index.ts) venv resolution, lazy spawn,
12
- │ the call queue, snapshots,
13
+ │ the call queue, output caps,
13
14
  │ abort grace, teardown
14
- └─ KernelClient (src/engine/kernel.ts) one ipykernel subprocess
15
- ├─ ZMTP 3.0 (src/engine/zmtp.ts) the wire protocol, by hand
16
- ├─ Jupyter session (src/engine/session.ts) framing + HMAC + JSON
17
- └─ python -m ipykernel -f <connection-file> the evaluator
15
+ └─ KernelClient (src/engine/kernel.ts) spawn the bridge, one JSON
16
+ │ line per op, route by id
17
+ └─ stdio pipe ─── bridge.py owns jupyter_client + ipykernel:
18
+ │ cells, streaming, snapshots,
19
+ │ restore, interrupts
20
+ └─ ipykernel the evaluator
18
21
  ```
19
22
 
20
- ## Why the host speaks ZMTP itself
23
+ ## Why the boundary is one pipe
21
24
 
22
- A TypeScript host cannot load libzmq's native Node bindings (they crash `bun`), and the earlier
23
- Python middleman (`guest.py`) that translated a private JSON protocol is gone. The host instead
24
- implements the small slice of ZMTP 3.0 a Jupyter client needs DEALER for shell/control, SUB for
25
- iopub (`src/engine/zmtp.ts`). The payoff:
25
+ An earlier iteration had the TypeScript host speak ZMTP 3.0, HMAC-sign every frame, and
26
+ re-implement the Jupyter client protocol by hand (`zmtp.ts`, `session.ts`, ~1,100 lines) because
27
+ libzmq's native bindings crash `bun`. Each layer compensated the one below it: the wire had no
28
+ auth so frames were signed; two channels raced so cells settled on a two-message protocol; the
29
+ machine could wedge so it grew eight watchdog timers. All of it is deleted.
26
30
 
27
- - **one process boundary** instead of two;
28
- - **one standard protocol** (Jupyter) instead of a private one on top of it;
29
- - **no invented framing** to maintain;
30
- - **messages are authenticated with HMAC** — the host signs and verifies every message with the
31
- kernel's HMAC key, replacing the old nonce that guarded against false completion messages.
31
+ The replacement is one stdio pipe to `bridge.py` (~250 lines), which owns everything Python-side
32
+ with ready-made libraries:
33
+
34
+ - **the file descriptor is the authentication** — the OS gives the pipe to exactly two processes;
35
+ - **one FIFO owner** the bridge's single-threaded loop serializes every op, so ordering needs
36
+ no protocol;
37
+ - **death is EOF plus an exit code** — no socket-liveness guessing; ipykernel also shuts itself
38
+ down when its parent (the bridge) dies;
39
+ - **the host caps output** exactly as before, but snapshot payloads never cross the pipe — the
40
+ bridge writes the snapshot file itself and replies with names and counts only.
32
41
 
33
42
  ## The Python environment (the venv)
34
43
 
35
44
  The evaluator is a real ipykernel, so it needs Python with `ipykernel` installed — a hard runtime
36
- dependency (`jupyter_client` is *not* needed: the host is the client). A package install runs
45
+ dependency. `jupyter_client` ships with ipykernel (the bridge drives the kernel through it), and
46
+ `cloudpickle` serializes snapshots (functions and classes by value). A package install runs
37
47
  `postinstall` (`scripts/setup-venv.mjs`), which builds a stable per-user venv at
38
48
  `~/.pi/agent/pi-repl/venv/bin/python3` — stable because it sits outside the package dir that npm
39
49
  replaces on each update. If `python3` or the network is missing at install time, it prints a
@@ -44,31 +54,32 @@ At spawn, `resolvePythonPath` uses exactly one interpreter: the install venv, el
44
54
  killed the kernel whenever cwd happened to contain one. The kernel starts in the session's cwd
45
55
  and falls back to the host cwd if that directory is gone, so a stale cwd never prevents boot.
46
56
 
47
- ## The kernel client
57
+ ## The bridge
48
58
 
49
- `KernelClient.start` spawns `python -m ipykernel -f <connection-file>` (a per-run connection file
50
- in the temp dir), connects the three channels over ZMTP, and waits for `kernel_info_reply` before
51
- declaring the kernel ready. Cells run as standard `execute_request`s, routed by `msg_id`:
59
+ `KernelClient.start` spawns `<venv python> bridge.py` and waits for its `ready` event; the bridge
60
+ starts ipykernel through `jupyter_client`, waits for `kernel_info_reply`, and preloads each
61
+ helper in its own cell (one broken helper fails alone). Host and bridge exchange one JSON object
62
+ per line:
52
63
 
53
- - **iopub** output: `stream`, `execute_result`, `display_data`, `error`, plus private-MIME
54
- payloads for snapshot/restore/namespace data;
55
- - **shell** the authoritative `execute_reply` (status, ename, evalue);
56
- - **control** — interrupts (`interrupt_request`) and shutdown.
64
+ - **host bridge**: `boot` (helper sources + snapshot policy), `exec`, `snapshot`, `restore`,
65
+ `listNames`, `interrupt`, `shutdown`;
66
+ - **bridge host**: `ready`, `stream` (cell output, streamed), `result` (a cell settles only
67
+ once the shell reply **and** iopub idle have arrived a tiny reply can beat a large output),
68
+ `reply` (op results), `error`.
57
69
 
58
- Four protocol details have contract tests.
70
+ Failure semantics have contract tests.
59
71
 
60
- **A cell settles only on two messages.** The shell reply and the iopub stream travel on different
61
- connections, so a tiny reply can beat a large output. A cell completes only when **both** the
62
- `execute_reply` and the matching iopub `status idle` (published after every byte) arrive;
63
- settling on the reply alone would drop output still in flight.
72
+ **Death is immediate and truthful.** When the kernel dies, the bridge exits; the host observes
73
+ the pipe's EOF and the exit code, settles the running cell with an error, and the next call
74
+ rebuilds from the last snapshot.
64
75
 
65
76
  **Output is capped per channel and per line**, both announced with markers: channels accumulate
66
- against `maxOutputChars` (checked within each message), and each line is capped at 4096 chars, so
77
+ against `maxOutputChars` (checked within each event), and each line is capped at 4096 chars, so
67
78
  one oversized line cannot own the budget while long JSON/reprs/errors pass whole.
68
79
 
69
- **Cancellation is real.** An abort sends `interrupt_request`, raising a genuine
80
+ **Cancellation is real.** An abort makes the bridge send `interrupt_request`, raising a genuine
70
81
  `KeyboardInterrupt`; the namespace survives. Cells wedged in C code (which ignore interrupts)
71
- get a 20-second grace, then the kernel is killed and the next call rebuilds from the last
82
+ get a 20-second grace, then the process group is killed and the next call rebuilds from the last
72
83
  snapshot.
73
84
 
74
85
  **History is off.** IPython's `In`/`Out` retention pins every last-expression result and cannot be
@@ -97,21 +108,30 @@ Full contract: [helpers.md](helpers.md).
97
108
 
98
109
  ## Snapshots & honest resets
99
110
 
100
- After each successful cell, a debounced snapshot pickles the kernel's `globals` entry by entry
101
- (one un-picklable value costs only itself) and publishes it back over a private MIME payload; the
102
- host stores it as `namespace.snapshot` under `~/.pi/agent/pi-repl/state/<session>/`.
103
-
104
- A fresh engine restores that snapshot **in the background**: recovery is a quiet-gap job that
105
- never runs ahead of a user cell, so a large revive does not delay the first cell; only a
106
- mid-session rebuild (kernel death) forces the restore before the cell that found the kernel dead.
107
- Functions and classes defined in cells are captured by source and re-executed on restore (plain
108
- pickle cannot revive them in `__main__`); bindings that still fail are reported by name, never
109
- dropped silently. Entries are capped per-binding and in total (128 MiB default); the file is
110
- written via temp-file-and-rename so a crash cannot corrupt the last good copy; a binding skipped
111
- at save time is named in the resume notice, never dropped silently, and a failed snapshot leaves
112
- the retry gate in place (only a persisted write advances it); a periodic refresh (default 2 min, `snapshot.periodMs`, 0 disables) bounds the loss window for same-name mutations and stands down when the last snapshot exceeded 8 MiB; value entries are zlib-compressed (file format version 3 — v1/v2 files remain restorable); session dirs are
113
- pruned to the newest 25, and dirs whose conversation file no longer exists are swept entirely
114
- deleting a conversation deletes its snapshots. "ephemeral" and the live session are exempt. A /fork'd conversation inherits the parent's last snapshot copied once into the fork's own key at first start, so it resumes with state, carries the standard reset marker on its first cell, and the human gets a dedicated fork toast; the parent is untouched.
111
+ After each successful cell, the bridge pickles the kernel's `globals` entry by entry with
112
+ cloudpickle (functions and classes serialize by value; one un-picklable binding costs only
113
+ itself), zlib-compresses each payload, and writes `namespace.snapshot` (format v4) under
114
+ `~/.pi/agent/pi-repl/state/<session>/` atomically. The gate — only a persisted write marks the
115
+ namespace as saved lives in the bridge too, and a pickling snapshot never runs ahead of a user
116
+ cell because the bridge's own loop is the queue.
117
+
118
+ A fresh engine restores that snapshot **in the background**: the revive is a quiet-gap job the
119
+ bridge runs only when its queue has been idle, so a large revive does not delay the first cell;
120
+ only a mid-session rebuild (kernel death) forces the restore before the cell that found the
121
+ kernel dead. Functions and classes revive through cloudpickle's by-value serialization;
122
+ bindings that still fail are reported by name, never dropped silently. Entries are capped
123
+ per-binding and in total (128 MiB default); the file is written via temp-file-and-rename so a
124
+ crash cannot corrupt the last good copy; a binding skipped at save time is named in the resume
125
+ notice, never dropped silently, and a failed snapshot leaves the retry gate in place (only a
126
+ persisted write advances it); a periodic refresh (default 2 min, `snapshot.periodMs`, 0
127
+ disables) bounds the loss window for same-name mutations and stands down when the last snapshot
128
+ exceeded 8 MiB; value payloads are zlib-compressed cloudpickle streams (file format version 4 —
129
+ v1/v2/v3 files remain restorable); session dirs are pruned to the newest 25, and dirs whose
130
+ conversation file no longer exists are swept entirely — deleting a conversation deletes its
131
+ snapshots. "ephemeral" and the live session are exempt. A /fork'd conversation inherits the
132
+ parent's last snapshot — copied once into the fork's own key at first start, so it resumes with
133
+ state, carries the standard reset marker on its first cell, and the human gets a dedicated fork
134
+ toast; the parent is untouched.
115
135
 
116
136
  A revive that never completes (a poisoned pickle) is bounded by an engine restore-cell watchdog
117
137
  (`PI_REPL_BOOT_TIMEOUT_MS`, default 90s): the kernel is killed and the restore marked skipped —
package/index.ts CHANGED
@@ -1,20 +1,26 @@
1
1
  // --- pi-repl: one execute tool over Python; everything else runs as functions inside it ---
2
2
 
3
- import { basename, join } from "node:path";
4
3
  import { homedir } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
- import { withSkillsBlock } from "./src/extension/skill-hook.js";
8
- import { buildHelpersPromptSection } from "./src/extension/helpers.js";
9
7
  import { EngineManager, pruneOrphanedSnapshotDirs, pruneSnapshotDirs } from "./src/engine/index.js";
8
+ import { buildHelpersPromptSection } from "./src/extension/helpers.js";
9
+ import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./src/extension/prompt.js";
10
10
  import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
11
- import { EngineLifecycle, formatForkToast, formatHelperFailuresLine, formatHelperToast, formatResetToast } from "./src/extension/session-engine.js";
11
+ import {
12
+ EngineLifecycle,
13
+ formatForkToast,
14
+ formatHelperFailuresLine,
15
+ formatHelperToast,
16
+ formatResetToast,
17
+ } from "./src/extension/session-engine.js";
18
+ import { withSkillsBlock } from "./src/extension/skill-hook.js";
12
19
  import { conversationName, inheritForkSnapshot, resolveStateDir } from "./src/extension/state-layout.js";
13
- import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
14
20
 
15
21
  const executeSchema = Type.Object({
16
22
  code: Type.String({
17
- description: "Python to execute in the persistent evaluator.",
23
+ description: "Python source for this cell; stdout, stderr, and the last-expression result are returned.",
18
24
  }),
19
25
  });
20
26
 
@@ -76,15 +82,23 @@ export default function (pi: ExtensionAPI) {
76
82
  // --- a /fork'd conversation inherits the parent's last namespace (copied once into the fork's own key) ---
77
83
  try {
78
84
  forkInherited = inheritForkSnapshot(stateRoot, sessionFile, snapshotPath);
79
- } catch {}
85
+ } catch (error) {
86
+ // a racing rename or unreadable session file: the fork starts empty, but loudly
87
+ console.error("[pi-repl] fork snapshot inheritance failed:", error);
88
+ }
80
89
  // --- keep the state root from growing one dir per session forever; the live dir is exempt ---
81
90
  try {
82
91
  pruneSnapshotDirs(stateRoot, 25, currentDir);
83
- } catch {}
92
+ } catch (error) {
93
+ console.error("[pi-repl] snapshot dir pruning failed:", error);
94
+ }
84
95
  // --- sweep state dirs whose conversation file exists in no project root: deleting a conversation deletes its snapshots (both dir formats) ---
85
96
  try {
86
97
  pruneOrphanedSnapshotDirs(stateRoot, sessionFile ? dirname(dirname(sessionFile)) : undefined, currentDir);
87
- } catch {}
98
+ } catch (error) {
99
+ // a readdir race with a concurrent sweep: the sweep retries next session, but loudly
100
+ console.error("[pi-repl] orphan snapshot sweep failed:", error);
101
+ }
88
102
  }
89
103
  return new EngineManager({
90
104
  cwd,
@@ -146,9 +160,9 @@ export default function (pi: ExtensionAPI) {
146
160
  pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
147
161
  name: "execute",
148
162
  label: "execute",
149
- description: EXECUTE_DESCRIPTION,
150
- promptSnippet: EXECUTE_PROMPT_SNIPPET,
151
- promptGuidelines: buildExecutePromptGuidelines(),
163
+ description: executeToolDescription,
164
+ promptSnippet: executePromptSnippet,
165
+ promptGuidelines: buildPromptGuidelines(),
152
166
  parameters: executeSchema,
153
167
  renderShell: "self",
154
168
  renderCall(args, theme, context) {
@@ -173,7 +187,9 @@ export default function (pi: ExtensionAPI) {
173
187
  },
174
188
  async execute(toolCallId, params, signal, onUpdate, ctx) {
175
189
  if (!active()) {
176
- throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
190
+ throw new Error(
191
+ "pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.",
192
+ );
177
193
  }
178
194
  if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
179
195
  // --- establish the body slot at call time so Ctrl+O can expand a live, still-streaming cell ---
@@ -194,47 +210,43 @@ export default function (pi: ExtensionAPI) {
194
210
  const reset = lifecycle.takeResetNotice();
195
211
  if (reset?.notice)
196
212
  ctx?.ui?.notify?.(
197
- m.inheritedFromFork ? formatForkToast(reset.restore) : formatResetToast(reset.origin, reset.restore, reset.wedged),
213
+ m.inheritedFromFork
214
+ ? formatForkToast(reset.restore)
215
+ : formatResetToast(reset.origin, reset.restore, reset.wedged),
198
216
  "info",
199
217
  );
200
218
  // --- helper verdicts once per boot: toast for the human, marker for the model only when a helper failed (all-good boots stay silent) ---
201
219
  const helperReport = m.takeHelperReport();
202
220
  if (helperReport && helperReport.length > 0) ctx?.ui?.notify?.(formatHelperToast(helperReport), "info");
203
- const sections = [
204
- reset?.notice,
205
- formatHelperFailuresLine(helperReport),
206
- r.stdout,
207
- r.stderr,
208
- r.result,
209
- ];
221
+ const sections = [reset?.notice, formatHelperFailuresLine(helperReport), r.stdout, r.stderr, r.result];
210
222
  const errorLines = r.error ? composeErrorLines(r.error) : undefined;
211
223
  if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
212
224
  if (r.status === "aborted") sections.push("[cell aborted]");
213
- const text = sections.filter((section) => section !== undefined && section !== "").join("\n");
225
+ const text = sections.filter((section) => section !== undefined && section !== "").join("\n");
214
226
 
215
- const details: ExecuteDetails = {
216
- status: r.status,
217
- durationMs: r.durationMs,
218
- errorName: r.error?.name,
219
- stdout: r.stdout || undefined,
220
- stderr: r.stderr || undefined,
221
- result: r.result,
222
- errorStack: errorLines,
223
- };
224
- const result = { content: [{ type: "text" as const, text: text || "(no output)" }], details };
225
- if (r.status === "error") {
226
- pendingErrorResults.set(toolCallId, { details });
227
- throw new Error(text || "(no output)");
228
- }
229
- // --- an aborted cell was interrupted, not wedged: the kernel keeps running, nothing to discard ---
230
- return result;
231
- } catch (error) {
232
- // --- a kernel that died (or was killed as the abort backstop) is down; drop it so the next cell rebuilds ---
233
- if (m.isRunning === false) {
234
- await lifecycle.discard();
227
+ const details: ExecuteDetails = {
228
+ status: r.status,
229
+ durationMs: r.durationMs,
230
+ errorName: r.error?.name,
231
+ stdout: r.stdout || undefined,
232
+ stderr: r.stderr || undefined,
233
+ result: r.result,
234
+ errorStack: errorLines,
235
+ };
236
+ const result = { content: [{ type: "text" as const, text: text || "(no output)" }], details };
237
+ if (r.status === "error") {
238
+ pendingErrorResults.set(toolCallId, { details });
239
+ throw new Error(text || "(no output)");
240
+ }
241
+ // --- an aborted cell was interrupted, not wedged: the kernel keeps running, nothing to discard ---
242
+ return result;
243
+ } catch (error) {
244
+ // --- a kernel that died (or was killed as the abort backstop) is down; drop it so the next cell rebuilds ---
245
+ if (m.isRunning === false) {
246
+ await lifecycle.discard();
247
+ }
248
+ throw error;
235
249
  }
236
- throw error;
237
- }
238
250
  },
239
251
  });
240
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
6
6
  "keywords": [
@@ -29,6 +29,7 @@
29
29
  "scripts",
30
30
  "docs",
31
31
  "index.ts",
32
+ "bridge.py",
32
33
  "README.md",
33
34
  "LICENSE"
34
35
  ],
@@ -2,19 +2,19 @@
2
2
  /** postinstall: build the stable per-user venv at ~/.pi/agent/pi-repl/venv; repair in place if ipykernel is missing; a bad build fails the install loudly. */
3
3
 
4
4
  import { execSync } from "node:child_process";
5
- import { mkdirSync } from "node:fs";
5
+ import { existsSync, mkdirSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
9
9
  const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl", "venv");
10
10
  const PY = join(VENV_DIR, "bin", "python3");
11
- const DEPS = ["ipykernel"];
11
+ const DEPS = ["ipykernel", "cloudpickle"];
12
12
  const HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
13
13
 
14
- // A venv that can't import ipykernel is broken — never trust the binary alone.
15
- function ipykernelOk() {
14
+ // A venv that can't import every runtime dep is broken — never trust the binary alone.
15
+ function depsOk() {
16
16
  try {
17
- execSync(`${PY} -c "import ipykernel"`, { stdio: "ignore" });
17
+ execSync(`${PY} -c "import ipykernel, cloudpickle"`, { stdio: "ignore" });
18
18
  return true;
19
19
  } catch {
20
20
  return false;
@@ -49,8 +49,8 @@ function seedHelpersDir() {
49
49
 
50
50
  function main() {
51
51
  seedHelpersDir();
52
- if (ipykernelOk()) {
53
- log(`venv ready (ipykernel present) at ${VENV_DIR}`);
52
+ if (depsOk()) {
53
+ log(`venv ready (${DEPS.join(", ")}) at ${VENV_DIR}`);
54
54
  return;
55
55
  }
56
56
  const systemPython = findSystemPython();
@@ -61,6 +61,19 @@ function main() {
61
61
  );
62
62
  return;
63
63
  }
64
+ // an existing venv missing a dep (e.g. cloudpickle added in an upgrade) is repaired in place;
65
+ // a missing/broken venv is rebuilt from a clean slate
66
+ if (existsSync(PY)) {
67
+ log(`repairing evaluator venv at ${VENV_DIR} (installing ${DEPS.join(" ")})...`);
68
+ try {
69
+ execSync(`${PY} -m pip install ${DEPS.join(" ")}`, { stdio: "inherit" });
70
+ if (!depsOk()) fail(`deps still not importable after install; the evaluator won't start.`);
71
+ log("done. The pi-repl evaluator will use this venv.");
72
+ return;
73
+ } catch (error) {
74
+ fail(`could not repair the evaluator venv (${error && error.message ? error.message : error}). `);
75
+ }
76
+ }
64
77
  log(`building evaluator venv at ${VENV_DIR} (uses ${systemPython})...`);
65
78
  try {
66
79
  mkdirSync(join(VENV_DIR, ".."), { recursive: true });
@@ -68,8 +81,8 @@ function main() {
68
81
  execSync(`${systemPython} -m venv --clear ${VENV_DIR}`, { stdio: "inherit" });
69
82
  execSync(`${PY} -m pip install --upgrade pip`, { stdio: "inherit" });
70
83
  execSync(`${PY} -m pip install ${DEPS.join(" ")}`, { stdio: "inherit" });
71
- if (!ipykernelOk()) {
72
- fail(`ipykernel still not importable after install; the evaluator won't start.`);
84
+ if (!depsOk()) {
85
+ fail(`runtime deps still not importable after install; the evaluator won't start.`);
73
86
  return;
74
87
  }
75
88
  log("done. The pi-repl evaluator will use this venv.");