pi-repl-py 0.6.13 → 0.7.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,8 +1,9 @@
1
1
  # Architecture
2
2
 
3
- pi-repl runs in **two processes**. pi hosts the TypeScript extension, and the extension manages a
4
- separate Python `ipykernel` process where user code runs. The host talks to that kernel using the
5
- standard Jupyter protocol. There is no Python middleman and no private framing layer between them.
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.
6
7
 
7
8
  ```
8
9
  pi
@@ -16,150 +17,111 @@ pi
16
17
  └─ python -m ipykernel -f <connection-file> the evaluator
17
18
  ```
18
19
 
19
- The host is TypeScript, and the evaluator is Python in a separate process. This means a cell can
20
- raise an exception or make the kernel unusable without taking pi down. The host can still report
21
- what happened.
22
-
23
20
  ## Why the host speaks ZMTP itself
24
21
 
25
- The obvious way for a TypeScript host to drive an `ipykernel` is to load a ZMQ client
26
- library. That does not work here: libzmq's native Node bindings crash `bun`. So an earlier
27
- design put a Python middleman (`guest.py`) between the host and the kernel, translating a
28
- private JSON protocol over a file descriptor into the real Jupyter protocol.
29
-
30
- The current design removes the middleman. Instead of working around the missing library, the
31
- host implements the small slice of ZMTP 3.0 that a Jupyter client needs. ZMTP is the socket
32
- protocol used by Jupyter's channels: the host uses a DEALER socket for shell and control, and a
33
- SUB socket for iopub (`src/engine/zmtp.ts`).
34
- The payoff:
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:
35
26
 
36
27
  - **one process boundary** instead of two;
37
28
  - **one standard protocol** (Jupyter) instead of a private one on top of it;
38
29
  - **no invented framing** to maintain;
39
- - **Messages are authenticated with HMAC.** The host signs and verifies Jupyter messages with
40
- the kernel's HMAC key. The earlier design used a nonce to prevent false completion messages;
41
- the standard message signature now provides that check.
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.
42
32
 
43
33
  ## The Python environment (the venv)
44
34
 
45
- The evaluator is a real `ipykernel` kernel, so it needs a Python environment with
46
- `ipykernel` installed. This is a hard runtime dependency. A script cannot replace it.
47
- (`jupyter_client` is *not* needed: the host is the client.)
35
+ 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
37
+ `postinstall` (`scripts/setup-venv.mjs`), which builds a stable per-user venv at
38
+ `~/.pi/agent/pi-repl/venv/bin/python3` — stable because it sits outside the package dir that npm
39
+ replaces on each update. If `python3` or the network is missing at install time, it prints a
40
+ notice and the host falls back at runtime.
48
41
 
49
- When installed as a pi package, `npm install` runs `postinstall`
50
- (`scripts/setup-venv.mjs`), which builds a stable per-user venv:
42
+ At spawn, `resolvePythonPath` uses exactly one interpreter: the install venv, else `$PYTHON` /
43
+ `python3`. It never auto-picks a repo or cwd `.venv` such a venv may lack ipykernel, which
44
+ killed the kernel whenever cwd happened to contain one. The kernel starts in the session's cwd
45
+ and falls back to the host cwd if that directory is gone, so a stale cwd never prevents boot.
51
46
 
52
- ```
53
- ~/.pi/agent/pi-repl/venv/bin/python3
54
- ```
47
+ ## The kernel client
55
48
 
56
- That path is stable across updates because it sits outside the package's own directory,
57
- which npm replaces on each update. If `python3` or the network is missing at install time,
58
- `postinstall` prints a clear notice and the host falls back at runtime.
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
52
 
60
- At spawn, `resolvePythonPath` uses exactly one interpreter, the install venv:
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.
61
57
 
62
- 1. `~/.pi/agent/pi-repl/venv` (the package install)
63
- 2. `$PYTHON`, then `python3` (only if the install venv is missing)
58
+ Four protocol details have contract tests.
64
59
 
65
- It deliberately does NOT auto-pick the repo's or current directory's `.venv`: a per-project
66
- venv is not guaranteed to have `ipykernel`, so preferring it (as earlier versions did) made
67
- the kernel die from a `ModuleNotFoundError` whenever cwd happened to contain such a venv. The
68
- kernel therefore always runs in the stable install environment. The kernel starts in the
69
- session's cwd, falling back to the host's own cwd if that directory no longer exists (a
70
- deleted project dir), so a stale cwd can never prevent the kernel from coming up.
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.
71
64
 
72
- ## The kernel client
65
+ **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
67
+ one oversized line cannot own the budget while long JSON/reprs/errors pass whole.
73
68
 
74
- `KernelClient.start` spawns `python -m ipykernel -f <connection-file>` with a per-run
75
- connection file in the temp directory, connects the three channels as ZMTP sockets, and
76
- waits for a `kernel_info_reply` before declaring the kernel ready. Cells run as standard
77
- Jupyter `execute_request`s, routed by `msg_id`:
78
-
79
- - **iopub** carries output messages such as `stream`, `execute_result`, `display_data`, and
80
- `error`. It also carries private-MIME payloads for snapshot, restore, and namespace data.
81
- - **shell** carries the authoritative `execute_reply` (status, ename, evalue).
82
- - **control** carries interrupts (`interrupt_request`) and shutdown.
83
-
84
- Two details of this protocol are important enough to have dedicated contract tests.
85
-
86
- **A cell is not complete until two messages arrive.** The shell reply and the iopub output stream
87
- travel on different connections, so a tiny reply can arrive before a large output has
88
- finished draining on iopub. A cell settles only when **both** the `execute_reply` and the
89
- matching iopub `status idle` (published after every byte of output) have arrived. Settling
90
- on the reply alone would drop output that was still in flight.
91
-
92
- **Output is capped per channel and per line.** Each channel accumulates output against a character
93
- budget (`maxOutputChars`), checked within each message, so overflow trips the moment a message
94
- exceeds the budget rather than when it churns on. Each individual line is also capped at a generous
95
- length (4096 chars), so a single genuinely oversized line cannot own the whole budget — while
96
- legitimately long REPL output (JSON, reprs, errors) still passes through whole. Both truncations
97
- are announced with explicit markers so the model knows output was cut.
98
-
99
- **Cancellation is real.** An abort sends an `interrupt_request` on the control channel,
100
- which raises a genuine `KeyboardInterrupt` in the running cell; the namespace survives. As a
101
- backstop for cells wedged in C code (which ignore interrupts), the engine gives an aborted
102
- cell up to 20 seconds to settle and keeps the kernel if it does; only a cell that is still
103
- running after that grace is killed, and the next call rebuilds from the last snapshot.
104
-
105
- **History is off.** Every execute goes out with `store_history: false`. IPython's `In`/`Out`
106
- retention keeps every last-expression result object alive in the kernel, and that retention
107
- cannot be reclaimed from a user cell — deleting `Out` and `_`/`__`/`___` from `user_ns`
108
- followed by `gc.collect()` leaves the objects alive (measured: 62 MB idle grows past 400 MB
109
- after two bare big results and never comes back). Disabling history bounds the kernel to at
110
- most the latest result. The transcript is the record instead, and results still publish over
111
- iopub: single-mode execution calls `sys.displayhook` regardless of `store_history`.
69
+ **Cancellation is real.** An abort sends `interrupt_request`, raising a genuine
70
+ `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
72
+ snapshot.
112
73
 
113
- ## Helpers loading
74
+ **History is off.** IPython's `In`/`Out` retention pins every last-expression result and cannot be
75
+ reclaimed from a user cell (measured: 62 MB → 400+ MB after two bare big results, unrecoverable),
76
+ so every execute goes out with `store_history: false`, bounding the kernel to the latest result.
77
+ Results still publish over iopub: single-mode execution calls `sys.displayhook` regardless.
114
78
 
115
- At boot, the kernel and the host both read the same merged helper list (project
116
- `.pi/helpers/` directories plus the global `~/.pi/agent/pi-repl/helpers/`), so what the
117
- prompt advertises is what the kernel holds. Both directories are optional; nothing ships
118
- with the package. The exact merge order is under "The fixed layout" below.
79
+ ## Helpers loading
119
80
 
120
- - **The kernel** executes each eligible `*.py` file in its namespace, so the file's definitions
121
- and imports become available.
122
- - **The host** reads the same files to build the helper list shown in the `execute` tool's
123
- prompt, so the model sees each `helper_description` verbatim.
81
+ At boot, the kernel and the host both read the same merged helper list (project `.pi/helpers/` up
82
+ to the git root, then the global `~/.pi/agent/pi-repl/helpers/`), so what the prompt advertises is
83
+ what the kernel holds: the kernel execs each eligible `*.py` into its namespace; the host reads
84
+ the same files' `helper_description` verbatim into the tool prompt. `_`-prefixed files are
85
+ skipped by both. Merge order and shadowing are under "The fixed layout" below.
124
86
 
125
- Both sides read the same list, so the names described to the model come from files the
126
- kernel also loads. A file renamed with a `_` prefix is skipped by both sides. The
127
- `promptGuidelines` are built once, when the `execute` tool is registered, so a helpers
128
- change needs a **session restart or `/reload`** to reach the prompt. The kernel also loads
129
- helpers only at boot.
87
+ The `promptGuidelines` are built once, when `execute` is registered, and the kernel loads helpers
88
+ only at boot, so a helpers change needs a **session restart or `/reload`**.
130
89
 
131
- There are no custom discovery intrinsics (`ls()` / `help()`) injected into a bare kernel.
132
- The model discovers what is loaded by listing the namespace with ordinary Python:
90
+ No discovery intrinsics (`ls()` / `help()`) are injected; list the namespace with ordinary Python:
133
91
 
134
92
  ```python
135
93
  [k for k in globals() if not k.startswith('_')]
136
94
  ```
137
95
 
138
- For the full helper contract, including descriptions, docstrings, and disabling, see
139
- [helpers.md](helpers.md).
96
+ Full contract: [helpers.md](helpers.md).
140
97
 
141
98
  ## Snapshots & honest resets
142
99
 
143
- After each successful cell, the host schedules a debounced snapshot. A private kernel cell
144
- pickles the kernel's `globals` (entry by entry, so one value that cannot be pickled costs
145
- only itself) and publishes the result back over a private MIME payload. The host stores it as
146
- `namespace.snapshot`, keyed to the session file under
147
- `~/.pi/agent/pi-repl/state/<session>/`.
148
-
149
- When a fresh engine is built, it restores that snapshot. Values are pickled entry by entry, and
150
- functions and classes defined in cells are captured by source and re-executed on restore (plain
151
- pickle cannot revive them, since they live in `__main__`). Bindings that still fail live
152
- handles, open resources, source-less functions are reported by name, never dropped silently.
153
- Entries are capped per-binding and in total (128 MiB default), and the snapshot file is written
154
- via temp-file-and-rename so a crash cannot corrupt the last good copy; old session snapshot
155
- directories are pruned to the newest 25, and snapshot dirs whose owning conversation file no
156
- longer exists in any project session root are swept entirely (deleting a conversation deletes
157
- its snapshots with it). "ephemeral" and the live session are always exempt. If the evaluator was rebuilt mid-session, the next
158
- cell's result is prefixed with a `<repl_engine_reset>` block that names what was revived and
159
- what was lost, so the model re-verifies before reusing state that may be gone. The human gets
160
- only a terse `ui.notify` toast ("repl kernel rebuilt, 3 names revived") instead of the marker;
161
- the two are derived from the same restore result, so they never disagree. A resumed
162
- conversation announces the same pair on its first cell, but only when the conversation has a
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.
115
+
116
+ A revive that never completes (a poisoned pickle) is bounded by an engine restore-cell watchdog
117
+ (`PI_REPL_BOOT_TIMEOUT_MS`, default 90s): the kernel is killed and the restore marked skipped
118
+ "wedged while reviving; skipped" instead of hanging the queue.
119
+
120
+ The first cell's result after a revive carries a `<repl_engine_reset>` block naming what was
121
+ revived and lost, so the model re-verifies before trusting state that may be gone; because
122
+ recovery is async, that is the first cell **after the restore completes** (usually the first cell
123
+ of a resumed conversation). The human gets only a terse `ui.notify` toast, derived from the same
124
+ restore result so the two never disagree. A resumed conversation announces only when it has a
163
125
  saved past; a first-ever session stays quiet.
164
126
 
165
127
  ## Failure modes
@@ -168,7 +130,7 @@ saved past; a first-ever session stays quiet.
168
130
  | --- | --- |
169
131
  | Cell throws | `error` status with traceback; kernel namespace intact |
170
132
  | Cell silent or wedged | the watchdog sends an `interrupt_request`; a caller abort kills the kernel only if it is still running after a 20-second grace |
171
- | Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot |
133
+ | Kernel dies | the running cell settles with an error; the next call builds a fresh kernel and restores the last snapshot **before** the triggering cell |
172
134
  | Host exits | `process.on("exit")` SIGKILLs live kernels (a child does not die with its parent) |
173
135
  | Output flood | capped per channel, truncation announced |
174
136
 
@@ -181,14 +143,14 @@ saved past; a first-ever session stays quiet.
181
143
  snapshot/restore round-trips, output caps, silence timeout, abort, and rebuilding from a
182
144
  snapshot after the kernel dies.
183
145
 
184
- The gate is `just check`. It runs Biome formatting and linting, dead-code checks, and host tests.
185
- `just integration` adds the real-kernel suite.
146
+ The gate is `just check` (Biome formatting/lint, dead-code checks, host tests); `just integration`
147
+ adds the real-kernel suite.
186
148
 
187
149
  ## The fixed layout
188
150
 
189
- There is no configuration file. Most state lives under one directory in the user's home. A
190
- small number of environment variables can still change runtime behavior, such as the silence
191
- watchdog timeout.
151
+ There is no configuration file. Most state lives under one directory in the user's home. A small
152
+ number of environment variables can still change runtime behavior, such as the silence watchdog
153
+ timeout.
192
154
 
193
155
  ```
194
156
  ~/.pi/agent/pi-repl/
@@ -197,21 +159,24 @@ watchdog timeout.
197
159
  state/ per-session namespace snapshots
198
160
  ```
199
161
 
200
- Helpers merge project and global dirs: `resolveHelperDirs` walks from the working
201
- directory up to the git root collecting `.pi/helpers/`, then appends
202
- `~/.pi/agent/pi-repl/helpers`. Both the prompt loader and the kernel's `readHelperSources`
203
- walk the same ordered list with first-seen-wins, so a project helper shadows the same-named
204
- global one and both sides are guaranteed to agree. The venv is built automatically, and the
205
- interpreter follows the order above. No setting is needed. The per-cell silence watchdog is
206
- off by default (`PI_REPL_TIMEOUT_MS=0`: a silent but working cell may run on).
207
-
208
- The boot itself is bounded regardless: kernel start, helpers preload, and snapshot restore are
209
- kernel cells with no deadline of their own, and `acquire()` dedupes, so one wedged boot (an npm
210
- update swapping the venv under a live kernel, a snapshot value whose unpickling never returns)
211
- would hang the first cell and every cell after it. The lifecycle races each boot attempt against
212
- `PI_REPL_BOOT_TIMEOUT_MS` (default 90s): a wedged attempt is killed and retried once with the
213
- snapshot deliberately skipped, landing on an honest "wedged while reviving; skipped" notice; a
214
- second wedge fails the cell loudly instead of hanging.
162
+ State dirs are keyed `<project-slug>__<conversation>` so two conversations that share a filename
163
+ can never share a snapshot (a pre-slug dir migrates on the owning conversation's next start), and
164
+ `EngineLifecycle.acquire` binds each engine to its conversation, tearing a foreign engine down
165
+ before building a new one sessions cannot bleed into each other.
166
+
167
+ Helpers merge project and global dirs: `resolveHelperDirs` walks up to the git root collecting
168
+ `.pi/helpers/`, then appends the global dir; the prompt loader and the kernel's `readHelperSources`
169
+ walk the same ordered list first-seen-wins, so a project helper shadows a same-named global one
170
+ and both sides agree. No setting is needed. The per-cell silence watchdog is off by default
171
+ (`PI_REPL_TIMEOUT_MS=0`).
172
+
173
+ Boot is bounded regardless: kernel start and helpers preload are kernel cells with no deadline of
174
+ their own, and `acquire()` dedupes, so one wedged boot (an npm update swapping the venv under a
175
+ live kernel, a hanging helper import) would hang the first cell and every cell after. The
176
+ lifecycle races each boot attempt against `PI_REPL_BOOT_TIMEOUT_MS` (default 90s): a wedged
177
+ attempt is killed and retried once with the snapshot deliberately skipped — "wedged while
178
+ reviving; skipped" — and a second wedge fails loudly. The same deadline bounds a restore whose
179
+ unpickling never returns.
215
180
 
216
181
  ## Reference documentation
217
182
 
package/docs/helpers.md CHANGED
@@ -79,6 +79,10 @@ A helper may define `helper_description`:
79
79
  helper_description = """double(x) — multiply a value by two."""
80
80
  ```
81
81
 
82
+ The value must be a **triple-quoted string** (`"""..."""` or `'''...'''`): a plain-quoted
83
+ assignment is not recognized, and the helper is advertised with a pointer text telling the
84
+ model to inspect it (`print(double.__doc__)`) instead.
85
+
82
86
  The host reads this value and puts it in the `execute` tool description verbatim. It is guidance for the model, not a registration mechanism or generated API. Keep it short: it
83
87
  is included in the model's context on every turn.
84
88
 
@@ -127,6 +131,15 @@ At startup, two parts of pi-repl read the same merged helper list (project dirs
127
131
  1. The kernel executes each eligible `.py` file. Its definitions become names in the Python workspace.
128
132
  2. The host reads `helper_description` to build the helper guidance shown to the model.
129
133
 
134
+ Both resolve the list from the **session's working directory** (not the folder pi was launched
135
+ from), so a resumed session advertises exactly the helpers its kernel loads. The per-session
136
+ list is rebuilt at every agent start and stated in the system prompt.
137
+
138
+ Each helper file executes in its **own cell**, so a broken helper (a syntax error or a top-level
139
+ raise) fails alone and does not stop the others. When a helper fails to load, the next cell's
140
+ output carries a `<repl_helpers_failed: name (error)>` line, and you get a toast; an
141
+ all-good boot stays silent.
142
+
130
143
  The host does not inspect `def` lines or infer signatures from filenames. A helper does not need to define one particular symbol. The file is the unit of loading; its public names are the names it defines or imports for use in
131
144
  the workspace.
132
145
 
package/index.ts CHANGED
@@ -5,9 +5,11 @@ import { homedir } from "node:os";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
  import { withSkillsBlock } from "./src/extension/skill-hook.js";
8
+ import { buildHelpersPromptSection } from "./src/extension/helpers.js";
8
9
  import { EngineManager, pruneOrphanedSnapshotDirs, pruneSnapshotDirs } from "./src/engine/index.js";
9
10
  import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
10
- import { EngineLifecycle, formatResetToast } from "./src/extension/session-engine.js";
11
+ import { EngineLifecycle, formatForkToast, formatHelperFailuresLine, formatHelperToast, formatResetToast } from "./src/extension/session-engine.js";
12
+ import { conversationName, inheritForkSnapshot, resolveStateDir } from "./src/extension/state-layout.js";
11
13
  import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
12
14
 
13
15
  const executeSchema = Type.Object({
@@ -58,33 +60,38 @@ export default function (pi: ExtensionAPI) {
58
60
  const pendingErrorResults = new Map<string, { details: ExecuteDetails }>();
59
61
 
60
62
  const lifecycle = new EngineLifecycle<EngineManager>({
61
- // --- boot deadline: bounds kernel start + helpers preload + snapshot restore. An npm
62
- // --- update swaps the venv and helpers under a live kernel, and the first boot after
63
- // --- it can wedge (poisoned pickle, half-built venv); without this the first cell
64
- // --- hangs forever, because acquire() dedupes onto the same hung boot. ---
63
+ // --- bound the boot: a wedged first boot would hang every cell (acquire() dedupes onto it) ---
65
64
  bootTimeoutMs: Number(process.env.PI_REPL_BOOT_TIMEOUT_MS ?? 90_000) || 90_000,
66
- create() {
65
+ create(skipRestore = false) {
67
66
  const { cwd, sessionFile } = location;
68
- const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
69
- // --- kernel namespace state lives under ~/.pi/agent/pi-repl, keyed by session, so it never clutters the project ---
70
- const stateDir = join(homedir(), ".pi", "agent", "pi-repl", "state", sessionKey ?? "ephemeral");
71
- // --- keep the state root from growing one dir per session forever; the live dir is exempt ---
72
- if (sessionKey) {
67
+ // --- state lives under ~/.pi/agent/pi-repl/state/<slug>__<conv>; conversations never share a snapshot; ephemeral sessions get none ---
68
+ const stateRoot = join(homedir(), ".pi", "agent", "pi-repl", "state");
69
+ let snapshot: { path: string } | undefined;
70
+ let forkInherited = false;
71
+ let currentDir: string | undefined;
72
+ if (sessionFile) {
73
+ const { dir, snapshotPath } = resolveStateDir(stateRoot, sessionFile);
74
+ currentDir = basename(dir);
75
+ snapshot = { path: snapshotPath };
76
+ // --- a /fork'd conversation inherits the parent's last namespace (copied once into the fork's own key) ---
73
77
  try {
74
- pruneSnapshotDirs(join(stateDir, ".."), 25, sessionKey);
78
+ forkInherited = inheritForkSnapshot(stateRoot, sessionFile, snapshotPath);
75
79
  } catch {}
76
- // --- cascade deletions: if a conversation is deleted, its snapshots die with it.
77
- // --- sessionFile is sessions/<project-root>/<name>.jsonl, so the sessions root is
78
- // --- two parent hops up; dirs whose conversation file exists in no project root
79
- // --- (and that aren't this session or the ephemeral fallback) are swept. ---
80
+ // --- keep the state root from growing one dir per session forever; the live dir is exempt ---
80
81
  try {
81
- pruneOrphanedSnapshotDirs(join(stateDir, ".."), sessionFile ? dirname(dirname(sessionFile)) : undefined, sessionKey);
82
+ pruneSnapshotDirs(stateRoot, 25, currentDir);
83
+ } catch {}
84
+ // --- sweep state dirs whose conversation file exists in no project root: deleting a conversation deletes its snapshots (both dir formats) ---
85
+ try {
86
+ pruneOrphanedSnapshotDirs(stateRoot, sessionFile ? dirname(dirname(sessionFile)) : undefined, currentDir);
82
87
  } catch {}
83
88
  }
84
89
  return new EngineManager({
85
90
  cwd,
86
- // --- snapshots are keyed to a session file; ephemeral sessions get none ---
87
- snapshot: sessionKey ? { path: join(stateDir, "namespace.snapshot") } : undefined,
91
+ // --- snapshots are per-conversation; skipRestore marks the wedged-boot retry ---
92
+ snapshot,
93
+ skipRestore,
94
+ forkInherited,
88
95
  });
89
96
  },
90
97
  async dispose(engine) {
@@ -104,14 +111,12 @@ export default function (pi: ExtensionAPI) {
104
111
  pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "execute"));
105
112
  return;
106
113
  }
107
- // --- active: the whole surface collapses to the one tool ---
108
114
  pi.setActiveTools(["execute"]);
109
- // --- warm the engine (and its revive) in the background; no popup. ---
110
- // --- acquire() dedupes, so the first execute awaits this same in-flight boot ---
115
+ // --- warm the engine in the background; acquire() dedupes, so the first execute awaits this same boot ---
111
116
  location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
112
- void lifecycle.acquire("startup").catch(() => {
113
- // --- boot/revive handled on the execute path; swallow so a background warm can never
114
- // --- surface an unhandled rejection. A resume's notice lands on the first cell. ---
117
+ const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
118
+ void lifecycle.acquire("startup", sessionKey).catch(() => {
119
+ // --- swallow the warm boot's rejection: boot/revive are handled on the execute path ---
115
120
  });
116
121
  });
117
122
 
@@ -128,14 +133,14 @@ export default function (pi: ExtensionAPI) {
128
133
  return { content: event.content, details: stashed.details, isError: true };
129
134
  });
130
135
 
131
- // --- pi gates skills on the read tool (absent in repl); re-emit them via withSkillsBlock. ---
132
- pi.on("before_agent_start", (event) => {
136
+ // --- pi gates skills on the read tool (absent in repl); the helper roster is rebuilt per session from the session cwd, not the launch cwd, so resumes advertise what the kernel loaded ---
137
+ pi.on("before_agent_start", (event, ctx) => {
133
138
  if (!active()) return;
134
- const systemPrompt = withSkillsBlock(
135
- event.systemPrompt,
136
- event.systemPromptOptions?.skills ?? [],
137
- );
138
- return systemPrompt === undefined ? undefined : { systemPrompt };
139
+ const skillsPrompt = withSkillsBlock(event.systemPrompt, event.systemPromptOptions?.skills ?? []);
140
+ let systemPrompt = skillsPrompt ?? event.systemPrompt;
141
+ const helpersBlock = buildHelpersPromptSection(ctx?.cwd ?? process.cwd());
142
+ if (helpersBlock) systemPrompt = `${systemPrompt}\n\n${helpersBlock}`;
143
+ return systemPrompt === event.systemPrompt ? undefined : { systemPrompt };
139
144
  });
140
145
 
141
146
  pi.registerTool<typeof executeSchema, ExecuteDetails, Partial<ExecuteRenderState>>({
@@ -143,7 +148,7 @@ export default function (pi: ExtensionAPI) {
143
148
  label: "execute",
144
149
  description: EXECUTE_DESCRIPTION,
145
150
  promptSnippet: EXECUTE_PROMPT_SNIPPET,
146
- promptGuidelines: buildExecutePromptGuidelines(process.cwd()),
151
+ promptGuidelines: buildExecutePromptGuidelines(),
147
152
  parameters: executeSchema,
148
153
  renderShell: "self",
149
154
  renderCall(args, theme, context) {
@@ -171,11 +176,10 @@ export default function (pi: ExtensionAPI) {
171
176
  throw new Error("pi-repl is dormant in this session. Start pi with --repl (or PI_REPL_FORCE=1) to use execute.");
172
177
  }
173
178
  if (ctx?.cwd) location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile?.() ?? undefined };
174
- // --- establish the body slot at call time so Ctrl+O can expand a live (still-awaiting) stream;
175
- // --- without this the host only renders the result once the first partial or the final result lands ---
179
+ // --- establish the body slot at call time so Ctrl+O can expand a live, still-streaming cell ---
176
180
  onUpdate?.({ content: [], details: {} });
177
- // --- previous engine died mid-session; acquire revives it ---
178
- const { engine: m } = await lifecycle.acquire("cell");
181
+ const sessionKey = location.sessionFile ? conversationName(location.sessionFile) : undefined;
182
+ const { engine: m } = await lifecycle.acquire("cell", sessionKey);
179
183
  try {
180
184
  // --- accumulate partial updates so the row height doesn't oscillate ---
181
185
  let streamed = "";
@@ -186,11 +190,23 @@ export default function (pi: ExtensionAPI) {
186
190
  onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
187
191
  },
188
192
  });
189
- // --- reset notice leads so the model reads that its namespace was rebuilt; the
190
- // --- human gets a terse notification instead of the marker, fire and forget ---
193
+ // --- reset notice leads so the model reads the rebuild; the human gets a terse toast instead ---
191
194
  const reset = lifecycle.takeResetNotice();
192
- if (reset?.notice) ctx?.ui?.notify?.(formatResetToast(reset.origin, reset.restore), "info");
193
- const sections = [reset?.notice, r.stdout, r.stderr, r.result];
195
+ if (reset?.notice)
196
+ ctx?.ui?.notify?.(
197
+ m.inheritedFromFork ? formatForkToast(reset.restore) : formatResetToast(reset.origin, reset.restore, reset.wedged),
198
+ "info",
199
+ );
200
+ // --- helper verdicts once per boot: toast for the human, marker for the model only when a helper failed (all-good boots stay silent) ---
201
+ const helperReport = m.takeHelperReport();
202
+ 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
+ ];
194
210
  const errorLines = r.error ? composeErrorLines(r.error) : undefined;
195
211
  if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
196
212
  if (r.status === "aborted") sections.push("[cell aborted]");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.13",
3
+ "version": "0.7.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": [
@@ -1,16 +1,5 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * postinstall: build the stable per-user Python venv the evaluator needs.
4
- *
5
- * On a package install the venv is built at a stable path the engine knows:
6
- *
7
- * ~/.pi/agent/pi-repl/venv/bin/python3
8
- *
9
- * The venv is repaired in place: if it exists but ipykernel is not importable,
10
- * this reflushes the venv and reinstalls rather than trusting a half-built one.
11
- * Failures are NOT silent: a bad build exits non-zero so `npm install` / `pi
12
- * install` visibly fails instead of leaving a broken evaluator.
13
- */
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. */
14
3
 
15
4
  import { execSync } from "node:child_process";
16
5
  import { mkdirSync } from "node:fs";
@@ -22,8 +11,7 @@ const PY = join(VENV_DIR, "bin", "python3");
22
11
  const DEPS = ["ipykernel"];
23
12
  const HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
24
13
 
25
- // A venv that exists but can't import ipykernel is broken. Never trust the
26
- // binary alone — a half-built venv otherwise looks "already up" forever.
14
+ // A venv that can't import ipykernel is broken never trust the binary alone.
27
15
  function ipykernelOk() {
28
16
  try {
29
17
  execSync(`${PY} -c "import ipykernel"`, { stdio: "ignore" });
@@ -50,10 +38,7 @@ function findSystemPython() {
50
38
  return null;
51
39
  }
52
40
 
53
- // ---------------------------------------------------------------- helpers dir
54
- // The helpers dir is user-owned. We create it empty on install. The REPL
55
- // provides shell and file IO natively; helpers are for things the user adds
56
- // themselves (e.g. web_search, custom skills). Existing files are never clobbered.
41
+ // --- helpers dir: user-owned, created empty; existing files are never clobbered ---
57
42
  function seedHelpersDir() {
58
43
  try {
59
44
  mkdirSync(HELPERS_DIR, { recursive: true });
@@ -89,9 +74,7 @@ function main() {
89
74
  }
90
75
  log("done. The pi-repl evaluator will use this venv.");
91
76
  } catch (error) {
92
- // A real failure must not exit 0 with a broken venv. npm/pi will see the
93
- // nonzero exit and report the install as failed instead of silently
94
- // handing the user a dead evaluator.
77
+ // a real failure must exit non-zero never hand the user a dead evaluator
95
78
  fail(`could not build the evaluator venv (${error && error.message ? error.message : error}). `);
96
79
  }
97
80
  }
@@ -101,4 +84,4 @@ function fail(m) {
101
84
  process.exit(1);
102
85
  }
103
86
 
104
- main();
87
+ main();
@@ -5,7 +5,6 @@ import { dirname, join, resolve } from "node:path";
5
5
 
6
6
  const GLOBAL_HELPERS_DIR = join(homedir(), ".pi", "agent", "pi-repl", "helpers");
7
7
 
8
- /** Ordered candidate dirs: nearest .pi/helpers up to the git root, then the global dir last. */
9
8
  export function resolveHelperDirs(cwd?: string, globalDir?: string): string[] {
10
9
  const dirs: string[] = [];
11
10
  if (cwd) {