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.
- package/ARCHITECTURE.md +141 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/docs/how-to-functions.md +107 -0
- package/docs/philosophy.md +88 -0
- package/index.ts +220 -0
- package/package.json +57 -0
- package/scripts/setup-venv.mjs +71 -0
- package/src/engine/guest.py +317 -0
- package/src/engine/index.ts +656 -0
- package/src/engine/protocol.ts +66 -0
- package/src/engine/toolbox/bash.py +72 -0
- package/src/engine/toolbox/edit.py +37 -0
- package/src/engine/toolbox/read.py +26 -0
- package/src/engine/toolbox/write.py +23 -0
- package/src/extension/config.ts +65 -0
- package/src/extension/preview-core.ts +518 -0
- package/src/extension/render-core.ts +348 -0
- package/src/extension/render.ts +93 -0
- package/src/extension/session-engine.ts +155 -0
- package/src/extension/tool-meta.ts +58 -0
- package/src/extension/toolbox.ts +74 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-repl-py",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi",
|
|
9
|
+
"agent",
|
|
10
|
+
"python",
|
|
11
|
+
"repl"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./index.ts"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"scripts",
|
|
22
|
+
"docs",
|
|
23
|
+
"index.ts",
|
|
24
|
+
"README.md",
|
|
25
|
+
"ARCHITECTURE.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"format": "biome format --write .",
|
|
34
|
+
"lint": "biome check .",
|
|
35
|
+
"knip": "knip",
|
|
36
|
+
"check": "tsc --noEmit && biome check . && npm run test:ts && npm run test:py",
|
|
37
|
+
"test:ts": "bun test test/units.test.ts test/preview-core.test.ts",
|
|
38
|
+
"test:py": ".venv/bin/python -m pytest test/guest_contract.py -q",
|
|
39
|
+
"test:integ": "bun test test/engine.integration.test.ts",
|
|
40
|
+
"postinstall": "node scripts/setup-venv.mjs"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*",
|
|
46
|
+
"@sinclair/typebox": "*"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@biomejs/biome": "2.3.5",
|
|
50
|
+
"@mariozechner/pi-coding-agent": "npm:@earendil-works/pi-coding-agent@^0.84.0",
|
|
51
|
+
"@mariozechner/pi-tui": "npm:@earendil-works/pi-tui@^0.84.0",
|
|
52
|
+
"@types/node": "^22.0.0",
|
|
53
|
+
"knip": "^6.32.0",
|
|
54
|
+
"typebox": "^1.3.11",
|
|
55
|
+
"typescript": "^5.6.0"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall: build the stable per-user Python venv the guest evaluator needs.
|
|
4
|
+
*
|
|
5
|
+
* The guest (src/engine/guest.py) runs a real ipykernel, which requires
|
|
6
|
+
* `ipykernel` + `jupyter_client` installed in a Python environment. When this
|
|
7
|
+
* is installed as a pi package there is no repo-local `.venv` (it's gitignored
|
|
8
|
+
* and excluded from the npm tarball), so we create one at a stable path that
|
|
9
|
+
* the engine also knows about:
|
|
10
|
+
*
|
|
11
|
+
* ~/.pi/agent/pi-repl-venv/bin/python3
|
|
12
|
+
*
|
|
13
|
+
* Failures are non-fatal: if there's no system python3 or no network, we print
|
|
14
|
+
* a clear notice and let the engine fall back to '$PYTHON' or 'python3' at
|
|
15
|
+
* runtime (where the user is told how to install the deps).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
const VENV_DIR = join(homedir(), ".pi", "agent", "pi-repl-venv");
|
|
24
|
+
const PY = join(VENV_DIR, "bin", "python3");
|
|
25
|
+
const DEPS = ["ipykernel", "jupyter_client"];
|
|
26
|
+
|
|
27
|
+
function log(msg) {
|
|
28
|
+
process.stdout.write(`[pi-repl] ${msg}\n`);
|
|
29
|
+
}
|
|
30
|
+
function warn(msg) {
|
|
31
|
+
process.stderr.write(`[pi-repl] warning: ${msg}\n`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findSystemPython() {
|
|
35
|
+
for (const cand of ["python3", "python"]) {
|
|
36
|
+
try {
|
|
37
|
+
execSync(`${cand} --version`, { stdio: "ignore" });
|
|
38
|
+
return cand;
|
|
39
|
+
} catch {}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function main() {
|
|
45
|
+
// Already built from a previous install/run.
|
|
46
|
+
if (existsSync(PY)) {
|
|
47
|
+
log(`venv already present at ${VENV_DIR}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const systemPython = findSystemPython();
|
|
51
|
+
if (!systemPython) {
|
|
52
|
+
warn(
|
|
53
|
+
`no python3 found on PATH; could not create the evaluator venv. ` +
|
|
54
|
+
`Install python3 and run '${PY.slice(-60)} -m venv' manually, or set PI_REPL pythonPath.`
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
log(`creating evaluator venv at ${VENV_DIR} (uses ${systemPython})...`);
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(join(VENV_DIR, ".."), { recursive: true });
|
|
61
|
+
execSync(`${systemPython} -m venv ${VENV_DIR}`, { stdio: "inherit" });
|
|
62
|
+
execSync(`${PY} -m pip install --upgrade pip`, { stdio: "inherit" });
|
|
63
|
+
execSync(`${PY} -m pip install ${DEPS.join(" ")}`, { stdio: "inherit" });
|
|
64
|
+
log("done. The pi-repl evaluator will use this venv.");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
warn(`could not build the evaluator venv (${error && error.message ? error.message : error}). `);
|
|
67
|
+
warn("You may need to run npm rebuild pi-repl after fixing python/network, or install ipykernel manually.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main();
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""
|
|
2
|
+
guest.py — the real IPython kernel guest evaluator for pi-repl.
|
|
3
|
+
|
|
4
|
+
The host spawns this once. It starts a local ipykernel subprocess via
|
|
5
|
+
jupyter_client, keeps it for the session, and bridges the wire protocol to it
|
|
6
|
+
(stdin = commands, fd 3 = results). State survives because the kernel process
|
|
7
|
+
does. Frames carry a nonce the host mints and the guest erases, so agent code
|
|
8
|
+
cannot forge protocol traffic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
# --- protocol envelope ---
|
|
19
|
+
ENVELOPE_KEY = "__rlm"
|
|
20
|
+
NONCE_ENV = "PI_RLM_NONCE"
|
|
21
|
+
PROTOCOL_FD = 3
|
|
22
|
+
|
|
23
|
+
NONCE = os.environ.get(NONCE_ENV, "")
|
|
24
|
+
os.environ.pop(NONCE_ENV, None)
|
|
25
|
+
|
|
26
|
+
# --- per-cell timeout: 0 = no cap; else a silence watchdog (no output for N seconds) ---
|
|
27
|
+
CELL_TIMEOUT_S = float(os.environ.get("PI_REPL_TIMEOUT_MS", "0") or "0") / 1000.0
|
|
28
|
+
|
|
29
|
+
# --- snapshot/restore use a fixed window, not the cell silence timer ---
|
|
30
|
+
SNAPSHOT_TIMEOUT_S = 90.0
|
|
31
|
+
|
|
32
|
+
# --- cap buffered output so a runaway print can't grow guest memory or send one giant frame ---
|
|
33
|
+
MAX_CELL_OUTPUT_CHARS = 1_000_000
|
|
34
|
+
|
|
35
|
+
# --- fd 3 protocol writer; dup'd so we don't close the caller's fd 3 on exit ---
|
|
36
|
+
_proto = os.fdopen(os.dup(PROTOCOL_FD), "w", buffering=1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _send(msg):
|
|
40
|
+
envelope = {ENVELOPE_KEY: 1, **msg}
|
|
41
|
+
if NONCE:
|
|
42
|
+
envelope["n"] = NONCE
|
|
43
|
+
_proto.write(json.dumps(envelope) + "\n")
|
|
44
|
+
_proto.flush()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _decode(line):
|
|
48
|
+
if ENVELOPE_KEY not in line:
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
obj = json.loads(line)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
if obj.get(ENVELOPE_KEY) != 1 or not isinstance(obj.get("type"), str):
|
|
55
|
+
return None
|
|
56
|
+
if NONCE and obj.get("n") != NONCE:
|
|
57
|
+
return None
|
|
58
|
+
return obj
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --- toolbox: one function per *.py, exec'd into every kernel (PI_TOOLBOX_DIR) ---
|
|
62
|
+
|
|
63
|
+
def _toolbox_files(directory):
|
|
64
|
+
"""Return {function_name: source} for each *.py in `directory`."""
|
|
65
|
+
if not directory:
|
|
66
|
+
return {}
|
|
67
|
+
d = os.path.expanduser(directory)
|
|
68
|
+
if not os.path.isdir(d):
|
|
69
|
+
return {}
|
|
70
|
+
names = {}
|
|
71
|
+
for entry in sorted(os.listdir(d)):
|
|
72
|
+
if not entry.endswith(".py"):
|
|
73
|
+
continue
|
|
74
|
+
name = entry[:-3]
|
|
75
|
+
if not name.isidentifier() or name.startswith("_"):
|
|
76
|
+
continue
|
|
77
|
+
try:
|
|
78
|
+
with open(os.path.join(d, entry), encoding="utf-8") as f:
|
|
79
|
+
names[name] = f.read()
|
|
80
|
+
except OSError:
|
|
81
|
+
continue
|
|
82
|
+
return names
|
|
83
|
+
|
|
84
|
+
DEFAULT_TOOLBOX_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "toolbox")
|
|
85
|
+
TOOLBOX_DIR = os.environ.get("PI_TOOLBOX_DIR", "").strip()
|
|
86
|
+
_TOOLBOX_SRC = _toolbox_files(TOOLBOX_DIR or DEFAULT_TOOLBOX_DIR)
|
|
87
|
+
|
|
88
|
+
# --- help/ls are part of the evaluator, not the toolbox ---
|
|
89
|
+
INTRINSIC = """
|
|
90
|
+
# --- ls() filters IPython-injected names out of the tool list ---
|
|
91
|
+
_RPL_LS_NOISE = {'exit', 'quit', 'get_ipython', 'open', 'display'}
|
|
92
|
+
|
|
93
|
+
def ls():
|
|
94
|
+
return sorted(n for n in globals() if n not in _RPL_LS_NOISE and not n.startswith('_') and callable(globals()[n]))
|
|
95
|
+
|
|
96
|
+
def help(name=None):
|
|
97
|
+
if name is None:
|
|
98
|
+
return ls()
|
|
99
|
+
fn = globals().get(name)
|
|
100
|
+
if fn is None or not callable(fn):
|
|
101
|
+
return f"no such function: {name!r}"
|
|
102
|
+
return fn.__doc__ or f"{name} (no docstring)"
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
from jupyter_client import KernelManager
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Kernel:
|
|
110
|
+
"""A persistent subprocess ipykernel + blocking client."""
|
|
111
|
+
|
|
112
|
+
def __init__(self):
|
|
113
|
+
self.km = KernelManager(kernel_name="python3")
|
|
114
|
+
self.km.start_kernel()
|
|
115
|
+
self.kc = self.km.client()
|
|
116
|
+
self.kc.start_channels()
|
|
117
|
+
self.kc.wait_for_ready(timeout=30)
|
|
118
|
+
self._preload()
|
|
119
|
+
|
|
120
|
+
def _preload(self):
|
|
121
|
+
"""Exec every toolbox function + the intrinsic help/ls into the kernel ns."""
|
|
122
|
+
code = INTRINSIC + "\n"
|
|
123
|
+
for src in _TOOLBOX_SRC.values():
|
|
124
|
+
code += src + "\n"
|
|
125
|
+
if code.strip():
|
|
126
|
+
self.kc.execute(code)
|
|
127
|
+
self._drain()
|
|
128
|
+
|
|
129
|
+
def _drain(self):
|
|
130
|
+
try:
|
|
131
|
+
while True:
|
|
132
|
+
m = self.kc.get_iopub_msg(timeout=1)
|
|
133
|
+
if m.get("msg_type") == "status" and m.get("content", {}).get("execution_state") == "idle":
|
|
134
|
+
break
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def _drain_execution(self, code, timeout):
|
|
139
|
+
"""Run `code`; return (stdout, stderr, error_text, result, timed_out).
|
|
140
|
+
|
|
141
|
+
`timeout <= 0` means "no cap": a cell runs until it reports idle.
|
|
142
|
+
`timeout > 0` is a SILENCE watchdog — it trips only once the cell has
|
|
143
|
+
produced no message for `timeout` seconds. A silent-but-running command
|
|
144
|
+
(e.g. `find ... | sort`) is allowed to complete; a stalled one (dead
|
|
145
|
+
kernel, or nothing for the silence window) reports `timed_out=True` so
|
|
146
|
+
the caller can surface a real hang instead of faking success.
|
|
147
|
+
"""
|
|
148
|
+
msg_id = self.kc.execute(code)
|
|
149
|
+
out, err, error, result = [], [], None, None
|
|
150
|
+
out_len, err_len = 0, 0
|
|
151
|
+
timed_out = False
|
|
152
|
+
# --- silence clock only starts once the cell begins (grace for a fresh kernel) ---
|
|
153
|
+
last_activity: float | None = None
|
|
154
|
+
while True:
|
|
155
|
+
if not self.km.is_alive():
|
|
156
|
+
timed_out = True
|
|
157
|
+
break
|
|
158
|
+
if last_activity is not None and timeout and (time.monotonic() - last_activity) >= timeout:
|
|
159
|
+
timed_out = True
|
|
160
|
+
break
|
|
161
|
+
wait = (timeout - (time.monotonic() - last_activity)) if (last_activity is not None and timeout) else 0.25
|
|
162
|
+
try:
|
|
163
|
+
m = self.kc.get_iopub_msg(timeout=max(0.01, min(0.25, wait)))
|
|
164
|
+
except Exception:
|
|
165
|
+
continue
|
|
166
|
+
if m.get("parent_header", {}).get("msg_id") != msg_id:
|
|
167
|
+
continue
|
|
168
|
+
if last_activity is None:
|
|
169
|
+
last_activity = time.monotonic()
|
|
170
|
+
else:
|
|
171
|
+
last_activity = time.monotonic()
|
|
172
|
+
mt = m.get("msg_type")
|
|
173
|
+
c = m.get("content", {})
|
|
174
|
+
if mt == "stream":
|
|
175
|
+
is_out = c.get("name") == "stdout"
|
|
176
|
+
text = c.get("text", "") or ""
|
|
177
|
+
if is_out:
|
|
178
|
+
if out_len < MAX_CELL_OUTPUT_CHARS:
|
|
179
|
+
take = text[: MAX_CELL_OUTPUT_CHARS - out_len]
|
|
180
|
+
out.append(take)
|
|
181
|
+
out_len += len(take)
|
|
182
|
+
else:
|
|
183
|
+
if err_len < MAX_CELL_OUTPUT_CHARS:
|
|
184
|
+
take = text[: MAX_CELL_OUTPUT_CHARS - err_len]
|
|
185
|
+
err.append(take)
|
|
186
|
+
err_len += len(take)
|
|
187
|
+
elif mt == "execute_result":
|
|
188
|
+
result = c.get("data", {}).get("text/plain")
|
|
189
|
+
elif mt == "error":
|
|
190
|
+
error = "\n".join(c.get("traceback", ["(no traceback)"]))
|
|
191
|
+
elif mt == "status" and c.get("execution_state") == "idle":
|
|
192
|
+
break
|
|
193
|
+
if timed_out:
|
|
194
|
+
# --- best-effort cancel so the NEXT cell doesn't queue behind this one ---
|
|
195
|
+
try:
|
|
196
|
+
self.kc.interrupt_kernel()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
return "".join(out), "".join(err), error, result, timed_out
|
|
200
|
+
|
|
201
|
+
def execute(self, code):
|
|
202
|
+
"""Idle-sync path used by snapshot/restore; not a user cell."""
|
|
203
|
+
return self._drain_execution(code, SNAPSHOT_TIMEOUT_S)[:4]
|
|
204
|
+
|
|
205
|
+
def run_cell(self, code):
|
|
206
|
+
"""Run a user cell under the per-cell timeout, so the model learns
|
|
207
|
+
when work did not finish."""
|
|
208
|
+
return self._drain_execution(code, CELL_TIMEOUT_S)
|
|
209
|
+
|
|
210
|
+
def snapshot_globals(self):
|
|
211
|
+
# --- snapshot only user state (skip toolbox/intrinsic functions and _ names) ---
|
|
212
|
+
tool_names = sorted(set(_TOOLBOX_SRC) | {"ls", "help", "function_description"})
|
|
213
|
+
skip_names = json.dumps(tool_names)
|
|
214
|
+
out, _, _, _ = self.execute(
|
|
215
|
+
"import pickle as _pk, base64 as _b64, json as _js\n"
|
|
216
|
+
"__rlm_skip = set(" + skip_names + ") | {'In','Out','get_ipython','exit','quit','open'}\n"
|
|
217
|
+
"__rlm_v = {}\n__rlm_f = []\n"
|
|
218
|
+
"for _k, _v in list(globals().items()):\n"
|
|
219
|
+
" # skip IPython bookkeeping and names with a leading underscore\n"
|
|
220
|
+
" if _k.startswith('__') or _k.startswith('_') or _k in __rlm_skip:\n"
|
|
221
|
+
" continue\n"
|
|
222
|
+
" try:\n"
|
|
223
|
+
" __rlm_v[_k] = _b64.b64encode(_pk.dumps(_v)).decode()\n"
|
|
224
|
+
" except Exception as _e:\n"
|
|
225
|
+
" __rlm_f.append({'name': _k, 'reason': str(_e)})\n"
|
|
226
|
+
"print('__RLC_SNAPSHOT__' + _js.dumps({'vars': __rlm_v, 'failed': __rlm_f}))\n"
|
|
227
|
+
)
|
|
228
|
+
marker = "__RLC_SNAPSHOT__"
|
|
229
|
+
if marker not in out:
|
|
230
|
+
# --- marker never printed: serialization stalled; report incomplete so the host keeps the last good file ---
|
|
231
|
+
return {}, [], False
|
|
232
|
+
try:
|
|
233
|
+
o = json.loads(out.split(marker)[-1])
|
|
234
|
+
return o.get("vars", {}), o.get("failed", []), True
|
|
235
|
+
except Exception:
|
|
236
|
+
return {}, [], False
|
|
237
|
+
|
|
238
|
+
def restore_globals(self, vars_):
|
|
239
|
+
if not vars_:
|
|
240
|
+
return [], []
|
|
241
|
+
# --- restore each variable in one atomic kernel call so one failure reports itself ---
|
|
242
|
+
code2 = (
|
|
243
|
+
"import pickle as _pk, base64 as _b64, json as _js\n"
|
|
244
|
+
"__rl_r = {'restored': [], 'failed': []}\n"
|
|
245
|
+
+ "\n".join(
|
|
246
|
+
"try:\n"
|
|
247
|
+
f" globals()[{name!r}] = _pk.loads(_b64.b64decode({b64!r}))\n"
|
|
248
|
+
f" __rl_r['restored'].append({name!r})\n"
|
|
249
|
+
"except Exception as _e:\n"
|
|
250
|
+
f" __rl_r['failed'].append({{'name': {name!r}, 'reason': str(_e)}})\n"
|
|
251
|
+
for name, b64 in vars_.items()
|
|
252
|
+
)
|
|
253
|
+
+ "\nprint('__RLC_RESTORE__' + _js.dumps(__rl_r))"
|
|
254
|
+
)
|
|
255
|
+
out, _, _, _ = self.execute(code2)
|
|
256
|
+
marker = "__RLC_RESTORE__"
|
|
257
|
+
if marker not in out:
|
|
258
|
+
return [], []
|
|
259
|
+
try:
|
|
260
|
+
obj = json.loads(out.split(marker)[-1])
|
|
261
|
+
return obj.get("restored", []), obj.get("failed", [])
|
|
262
|
+
except Exception:
|
|
263
|
+
return [], []
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _line_error(text):
|
|
267
|
+
lines = text.split("\n")
|
|
268
|
+
return {"name": "", "message": text, "stack": lines[:12]}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def main():
|
|
272
|
+
kernel = Kernel()
|
|
273
|
+
_send({"type": "ready"})
|
|
274
|
+
|
|
275
|
+
for line in sys.stdin:
|
|
276
|
+
msg = _decode(line)
|
|
277
|
+
if not msg:
|
|
278
|
+
continue
|
|
279
|
+
t = msg["type"]
|
|
280
|
+
if t == "ping":
|
|
281
|
+
_send({"type": "pong", "id": msg["id"]})
|
|
282
|
+
elif t == "snapshot":
|
|
283
|
+
vars_, failed, complete = kernel.snapshot_globals()
|
|
284
|
+
_send({"type": "snapshot_result", "id": msg["id"], "vars": vars_, "failed": failed, "complete": complete})
|
|
285
|
+
elif t == "restore":
|
|
286
|
+
restored, failed = kernel.restore_globals(msg.get("vars", {}))
|
|
287
|
+
_send({"type": "restore_result", "id": msg["id"], "restored": restored, "failed": failed})
|
|
288
|
+
elif t == "list_names":
|
|
289
|
+
names = list(kernel.snapshot_globals()[0].keys())
|
|
290
|
+
_send({"type": "names_result", "id": msg["id"], "names": names})
|
|
291
|
+
elif t == "run":
|
|
292
|
+
cell_id = msg.get("cellId")
|
|
293
|
+
stdout, stderr, error, result, timed_out = kernel.run_cell(msg.get("code", ""))
|
|
294
|
+
if stdout:
|
|
295
|
+
_send({"type": "stream", "cellId": cell_id, "name": "stdout", "chunk": stdout})
|
|
296
|
+
if stderr:
|
|
297
|
+
_send({"type": "stream", "cellId": cell_id, "name": "stderr", "chunk": stderr})
|
|
298
|
+
if timed_out:
|
|
299
|
+
tmsg = {
|
|
300
|
+
"name": "Timeout",
|
|
301
|
+
"message": f"cell did not finish within {CELL_TIMEOUT_S:g}s and may still be running",
|
|
302
|
+
"stack": ["[cell timed out]"],
|
|
303
|
+
}
|
|
304
|
+
_send({"type": "done", "cellId": cell_id, "status": "error", "error": tmsg})
|
|
305
|
+
elif error:
|
|
306
|
+
_send({"type": "done", "cellId": cell_id, "status": "error", "error": _line_error(error)})
|
|
307
|
+
else:
|
|
308
|
+
_send({"type": "done", "cellId": cell_id, "status": "ok", "result": result})
|
|
309
|
+
# --- a single-threaded guest can't read 'abort' mid-cell; the host discards+rebuilds ---
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
try:
|
|
314
|
+
main()
|
|
315
|
+
except Exception as e:
|
|
316
|
+
_send({"type": "done", "cellId": "", "status": "error", "error": _line_error(str(e))})
|
|
317
|
+
sys.exit(1)
|