@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10
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/CHANGELOG.md +42 -0
- package/README.md +100 -4
- package/package.json +4 -4
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +4 -0
- package/src/bridges/eval-virtual-schemas.ts +1 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-installer.ts +64 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +2 -0
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +21 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +10 -2
- package/src/index.ts +2 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/context-manager.ts +25 -15
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/local-module-loader.ts +21 -5
- package/src/kernels/js/worker-core.js +2 -0
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-runtime.js +3 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +112 -9
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/sandbox-cell.ts +116 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/tool/cell-handler.ts +10 -12
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +16 -4
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/run-eval-cell.ts +57 -0
- package/src/tool/types.ts +48 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
// A cell's bare import runs `import()` from this worker's own module, so the runtime resolves it from Senpi's install
|
|
7
|
+
// tree, not the session's project or its managed package revision. This resolver walks `node_modules` from each root
|
|
8
|
+
// the cell names, in order, and resolves the package entry the way ESM does (the `exports` field with import
|
|
9
|
+
// conditions), so ESM-only packages load and the first root that has the package wins.
|
|
10
|
+
const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
|
|
11
|
+
const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
|
|
12
|
+
|
|
13
|
+
function isFile(path) {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path).isFile();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDirectory(path) {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(path).isDirectory();
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitSpecifier(specifier) {
|
|
30
|
+
const parts = specifier.split("/");
|
|
31
|
+
const nameParts = specifier.startsWith("@") ? 2 : 1;
|
|
32
|
+
if (parts.length < nameParts || parts.slice(0, nameParts).some((part) => part === "" || part === "." || part === ".."))
|
|
33
|
+
return undefined;
|
|
34
|
+
const rest = parts.slice(nameParts).join("/");
|
|
35
|
+
return { name: parts.slice(0, nameParts).join("/"), subpath: rest === "" ? "." : `./${rest}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function conditionalTarget(target) {
|
|
39
|
+
if (typeof target === "string") return target;
|
|
40
|
+
if (Array.isArray(target)) {
|
|
41
|
+
for (const candidate of target) {
|
|
42
|
+
const resolved = conditionalTarget(candidate);
|
|
43
|
+
if (resolved !== undefined) return resolved;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (target === null || typeof target !== "object") return undefined;
|
|
48
|
+
for (const [condition, value] of Object.entries(target)) {
|
|
49
|
+
if (condition !== "default" && !CONDITIONS.includes(condition)) continue;
|
|
50
|
+
const resolved = conditionalTarget(value);
|
|
51
|
+
if (resolved !== undefined) return resolved;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function exportsTarget(exportsField, subpath) {
|
|
57
|
+
const subpathKeyed =
|
|
58
|
+
exportsField !== null &&
|
|
59
|
+
typeof exportsField === "object" &&
|
|
60
|
+
!Array.isArray(exportsField) &&
|
|
61
|
+
Object.keys(exportsField).some((key) => key.startsWith("."));
|
|
62
|
+
if (!subpathKeyed) return subpath === "." ? conditionalTarget(exportsField) : undefined;
|
|
63
|
+
if (Object.hasOwn(exportsField, subpath)) return conditionalTarget(exportsField[subpath]);
|
|
64
|
+
let best;
|
|
65
|
+
for (const key of Object.keys(exportsField)) {
|
|
66
|
+
const star = key.indexOf("*");
|
|
67
|
+
if (star < 0 || key.includes("*", star + 1)) continue;
|
|
68
|
+
const prefix = key.slice(0, star);
|
|
69
|
+
const suffix = key.slice(star + 1);
|
|
70
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
|
|
71
|
+
continue;
|
|
72
|
+
if (best === undefined || prefix.length > best.prefix.length) {
|
|
73
|
+
best = { key, prefix, match: subpath.slice(prefix.length, subpath.length - suffix.length) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (best === undefined || hasInvalidSegment(best.match)) return undefined;
|
|
77
|
+
const target = conditionalTarget(exportsField[best.key]);
|
|
78
|
+
return target === undefined ? undefined : target.replaceAll("*", best.match);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Node refuses a pattern match or target with "." / ".." / "node_modules" segments (ERR_INVALID_MODULE_SPECIFIER).
|
|
82
|
+
function hasInvalidSegment(path) {
|
|
83
|
+
return path.split(/[\\/]/).some((segment) => segment === "." || segment === ".." || segment === "node_modules");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function legacyFile(path) {
|
|
87
|
+
for (const candidate of [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")]) {
|
|
88
|
+
if (isFile(candidate)) return candidate;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageEntry(packageDir, subpath, specifier) {
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
|
|
99
|
+
}
|
|
100
|
+
if (manifest.exports !== undefined && manifest.exports !== null) {
|
|
101
|
+
const target = exportsTarget(manifest.exports, subpath);
|
|
102
|
+
if (typeof target !== "string" || !target.startsWith("./") || hasInvalidSegment(target.slice(2))) {
|
|
103
|
+
const error = new Error(`Package subpath '${subpath}' is not exported for import by '${specifier}'`);
|
|
104
|
+
error.code = "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return join(packageDir, target);
|
|
108
|
+
}
|
|
109
|
+
if (subpath !== ".") return legacyFile(join(packageDir, subpath));
|
|
110
|
+
const moduleEntry = typeof globalThis.Bun === "object" && typeof manifest.module === "string" ? manifest.module : undefined;
|
|
111
|
+
const main = moduleEntry ?? (typeof manifest.main === "string" ? manifest.main : "index.js");
|
|
112
|
+
return legacyFile(join(packageDir, main));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findPackageDir(name, rootUrl) {
|
|
116
|
+
let directory = fileURLToPath(rootUrl);
|
|
117
|
+
for (;;) {
|
|
118
|
+
const candidate = join(directory, "node_modules", name);
|
|
119
|
+
if (isDirectory(candidate)) return candidate;
|
|
120
|
+
const parent = dirname(directory);
|
|
121
|
+
if (parent === directory) return undefined;
|
|
122
|
+
directory = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The file URL the bare `specifier` resolves to from the first of `rootUrls` whose `node_modules` chain has its
|
|
128
|
+
* package, or undefined when it names a builtin or no root has the package (the caller then imports it natively).
|
|
129
|
+
*/
|
|
130
|
+
export function resolvePackage(specifier, rootUrls) {
|
|
131
|
+
if (specifier.includes(":") || isBuiltin(specifier)) return undefined;
|
|
132
|
+
const parts = splitSpecifier(specifier);
|
|
133
|
+
if (parts === undefined) return undefined;
|
|
134
|
+
for (const rootUrl of rootUrls) {
|
|
135
|
+
if (typeof rootUrl !== "string") continue;
|
|
136
|
+
const packageDir = findPackageDir(parts.name, rootUrl);
|
|
137
|
+
if (packageDir === undefined) continue;
|
|
138
|
+
const entry = packageEntry(packageDir, parts.subpath, specifier);
|
|
139
|
+
if (entry === undefined) continue;
|
|
140
|
+
let real = entry;
|
|
141
|
+
try {
|
|
142
|
+
real = realpathSync(entry);
|
|
143
|
+
} catch {
|
|
144
|
+
// A missing target fails in the import itself, with the runtime's own error.
|
|
145
|
+
}
|
|
146
|
+
return pathToFileURL(real.endsWith(sep) ? real.slice(0, -1) : real).href;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function installPackageResolver() {
|
|
152
|
+
globalThis[RESOLVE_PACKAGE] = resolvePackage;
|
|
153
|
+
}
|
|
@@ -65,13 +65,15 @@ export class JsWorkerRuntime {
|
|
|
65
65
|
try {
|
|
66
66
|
let prelude = "";
|
|
67
67
|
let cellCode = code;
|
|
68
|
+
let sourceName = cellId;
|
|
68
69
|
if (code.startsWith(PREPARED_CELL_PREFIX)) {
|
|
69
70
|
const prepared = JSON.parse(code.slice(PREPARED_CELL_PREFIX.length));
|
|
70
71
|
if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
|
|
71
72
|
({ prelude, code: cellCode } = prepared);
|
|
73
|
+
if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
|
|
72
74
|
}
|
|
73
75
|
if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
|
|
74
|
-
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)),
|
|
76
|
+
const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
|
|
75
77
|
await this.#drainPendingDisplays();
|
|
76
78
|
return value;
|
|
77
79
|
} finally {
|
|
@@ -4,8 +4,11 @@ import { kernelToolError } from "./kernel-tools-errors.js";
|
|
|
4
4
|
// Only host-tool sugar: no admission, worker, or queue state belongs to the kernel.
|
|
5
5
|
export async function createWorkpool(callTool, agent, name, options = {}) {
|
|
6
6
|
if (inKernelToolInvoke()) throw kernelToolError("kernel_tool_recursion", "kernel tools may not invoke workpool()");
|
|
7
|
-
if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode")) {
|
|
8
|
-
throw new TypeError("workpool() options only accept mode");
|
|
7
|
+
if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode" && key !== "tools")) {
|
|
8
|
+
throw new TypeError("workpool() options only accept mode and tools");
|
|
9
|
+
}
|
|
10
|
+
if (options.tools !== undefined && (!Array.isArray(options.tools) || !options.tools.every(name => typeof name === "string"))) {
|
|
11
|
+
throw workpoolError("invalid_tools", `workpool() tools takes an array of tool names; got ${Array.isArray(options.tools) ? "an array with a non-string" : typeof options.tools}`);
|
|
9
12
|
}
|
|
10
13
|
const call = async args => {
|
|
11
14
|
try {
|
|
@@ -1,42 +1,19 @@
|
|
|
1
1
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
-
|
|
4
|
-
type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
|
|
5
|
-
|
|
3
|
+
import { runHostCell as runSharedHostCell } from "../shared/host-cell.ts";
|
|
6
4
|
import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
|
|
7
|
-
import { failedPythonResult } from "./transport.ts";
|
|
8
5
|
|
|
9
6
|
export function runHostCell(
|
|
10
7
|
pending: PendingRun,
|
|
11
8
|
host: HostCellExecutor,
|
|
12
9
|
io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
|
|
13
10
|
): void {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const emit = (message: KernelToHostMessage): void => {
|
|
20
|
-
if (!finished && !abort.signal.aborted) io.emit(message);
|
|
21
|
-
};
|
|
22
|
-
// Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
|
|
23
|
-
const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
|
|
24
|
-
(outcome): ResultMessage =>
|
|
25
|
-
outcome.ok
|
|
26
|
-
? {
|
|
27
|
-
type: "result",
|
|
28
|
-
cellId,
|
|
29
|
-
ok: true,
|
|
30
|
-
durationMs: 0,
|
|
31
|
-
...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
|
|
32
|
-
}
|
|
33
|
-
: { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
|
|
34
|
-
(error: unknown): ResultMessage =>
|
|
35
|
-
failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
|
|
36
|
-
);
|
|
37
|
-
pending.hostDone = done;
|
|
38
|
-
void done.then((result) => {
|
|
39
|
-
finished = true;
|
|
40
|
-
if (!abort.signal.aborted) io.settle(result);
|
|
11
|
+
const run = runSharedHostCell(pending.input.cellId, host, {
|
|
12
|
+
emit: io.emit,
|
|
13
|
+
settle: io.settle,
|
|
14
|
+
durationMs: () => 0,
|
|
15
|
+
settleAfterAbort: false,
|
|
41
16
|
});
|
|
17
|
+
pending.hostAbort = run.abort;
|
|
18
|
+
pending.hostDone = run.done;
|
|
42
19
|
}
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
KernelToolsInvokeOptions,
|
|
6
6
|
KernelToolsInvokeRequest,
|
|
7
7
|
} from "../js/kernel-tools-types.ts";
|
|
8
|
+
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
8
9
|
import { describeExit } from "../shared/kernel-death.ts";
|
|
9
10
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
10
11
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
@@ -243,10 +244,15 @@ export class PythonKernel {
|
|
|
243
244
|
});
|
|
244
245
|
return;
|
|
245
246
|
}
|
|
247
|
+
const input = inputAtStart(pending.input);
|
|
248
|
+
if ("refused" in input) {
|
|
249
|
+
this.#settleRun(pending, failedPythonResult(pending.input.cellId, input.refused));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
246
252
|
try {
|
|
247
253
|
this.#transport?.run({
|
|
248
|
-
...
|
|
249
|
-
preludePlan: this.#preludes.plan(
|
|
254
|
+
...input,
|
|
255
|
+
preludePlan: this.#preludes.plan(input.kernelPreludes ?? []),
|
|
250
256
|
});
|
|
251
257
|
} catch (error) {
|
|
252
258
|
const failure = error instanceof Error ? error : new Error(String(error));
|
|
@@ -13,6 +13,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
|
|
|
13
13
|
import base64
|
|
14
14
|
import codecs
|
|
15
15
|
import contextlib
|
|
16
|
+
import importlib
|
|
16
17
|
import gc
|
|
17
18
|
import inspect
|
|
18
19
|
import io
|
|
@@ -374,7 +375,15 @@ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
|
|
|
374
375
|
_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
375
376
|
|
|
376
377
|
|
|
378
|
+
# The host's secret for the run in this context. Every host call carries it, so the host gives the call that run's
|
|
379
|
+
# kernel tools; copied contexts (parallel workers) keep it, plain threads do not, and the host forgets it at settle.
|
|
380
|
+
CURRENT_CELL_TOKEN: contextvars.ContextVar[str | None] = contextvars.ContextVar("senpi_cell_token", default=None)
|
|
381
|
+
|
|
382
|
+
|
|
377
383
|
def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
|
|
384
|
+
cell_token = CURRENT_CELL_TOKEN.get()
|
|
385
|
+
if path == "/call" and cell_token is not None and "cellToken" not in payload:
|
|
386
|
+
payload = {**payload, "cellToken": cell_token}
|
|
378
387
|
port = CONNECTION.get("port")
|
|
379
388
|
token = CONNECTION.get("token")
|
|
380
389
|
if not isinstance(port, int) or not isinstance(token, str):
|
|
@@ -1088,10 +1097,18 @@ class Workpool:
|
|
|
1088
1097
|
return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
|
|
1089
1098
|
|
|
1090
1099
|
|
|
1091
|
-
def workpool(
|
|
1100
|
+
def workpool(
|
|
1101
|
+
agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
|
|
1102
|
+
) -> Workpool:
|
|
1092
1103
|
args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
|
|
1093
1104
|
if mode is not None:
|
|
1094
1105
|
args["mode"] = mode
|
|
1106
|
+
if tools is not None:
|
|
1107
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1108
|
+
raise PreludeRuntimeError(
|
|
1109
|
+
f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
|
|
1110
|
+
)
|
|
1111
|
+
args["tools"] = list(tools)
|
|
1095
1112
|
result = _workpool_call(args)
|
|
1096
1113
|
details = result.get("details")
|
|
1097
1114
|
if isinstance(details, dict):
|
|
@@ -1169,9 +1186,12 @@ def agent(
|
|
|
1169
1186
|
apply: bool | None = None,
|
|
1170
1187
|
merge: bool | str | None = None,
|
|
1171
1188
|
handle: bool = False,
|
|
1189
|
+
tools: list[str] | None = None,
|
|
1172
1190
|
) -> Any:
|
|
1173
1191
|
"""Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
|
|
1174
1192
|
|
|
1193
|
+
tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
|
|
1194
|
+
|
|
1175
1195
|
merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
|
|
1176
1196
|
changes raise an error with recovery instructions. A handle returns immediately;
|
|
1177
1197
|
await the completion notification or read task_output for the isolation result.
|
|
@@ -1193,6 +1213,10 @@ def agent(
|
|
|
1193
1213
|
args["merge"] = merge
|
|
1194
1214
|
if handle:
|
|
1195
1215
|
args["handle"] = True
|
|
1216
|
+
if tools is not None:
|
|
1217
|
+
if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
|
|
1218
|
+
raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
|
|
1219
|
+
args["tools"] = list(tools)
|
|
1196
1220
|
|
|
1197
1221
|
response = bridge_post(
|
|
1198
1222
|
"/call",
|
|
@@ -1577,8 +1601,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
|
|
|
1577
1601
|
}
|
|
1578
1602
|
|
|
1579
1603
|
|
|
1604
|
+
_HOST_MAGICS = frozenset({"pip", "environment"})
|
|
1605
|
+
|
|
1606
|
+
|
|
1580
1607
|
def _magic(name: str, args: str) -> Any:
|
|
1581
1608
|
handler = _LINE_MAGICS.get(name)
|
|
1609
|
+
if handler is None and name in _HOST_MAGICS:
|
|
1610
|
+
# The host runs these only as a cell's first code line; anywhere else they reach here.
|
|
1611
|
+
raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
|
|
1582
1612
|
if handler is None:
|
|
1583
1613
|
raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
|
|
1584
1614
|
return handler(args)
|
|
@@ -2069,8 +2099,8 @@ KERNEL_MEMORY = _KernelMemory()
|
|
|
2069
2099
|
TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
|
|
2070
2100
|
|
|
2071
2101
|
|
|
2072
|
-
def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
2073
|
-
module = ast.parse(transform_cell(source), mode="exec")
|
|
2102
|
+
def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
|
|
2103
|
+
module = ast.parse(transform_cell(source), filename=filename, mode="exec")
|
|
2074
2104
|
if not module.body:
|
|
2075
2105
|
return None, None
|
|
2076
2106
|
last = module.body[-1]
|
|
@@ -2078,13 +2108,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
|
2078
2108
|
body = ast.Module(body=module.body[:-1], type_ignores=[])
|
|
2079
2109
|
expression = ast.Expression(body=last.value)
|
|
2080
2110
|
ast.copy_location(expression, last)
|
|
2081
|
-
return compile(body,
|
|
2111
|
+
return compile(body, filename, "exec", flags=TLA_FLAG), compile(
|
|
2082
2112
|
expression,
|
|
2083
|
-
|
|
2113
|
+
filename,
|
|
2084
2114
|
"eval",
|
|
2085
2115
|
flags=TLA_FLAG,
|
|
2086
2116
|
)
|
|
2087
|
-
return compile(module,
|
|
2117
|
+
return compile(module, filename, "exec", flags=TLA_FLAG), None
|
|
2088
2118
|
|
|
2089
2119
|
|
|
2090
2120
|
async def run_code(code: Any, want_value: bool) -> Any:
|
|
@@ -2111,7 +2141,41 @@ def apply_preludes(preludes: Any) -> None:
|
|
|
2111
2141
|
exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
|
|
2112
2142
|
|
|
2113
2143
|
|
|
2114
|
-
def
|
|
2144
|
+
def _enter_source_file(source_file: str | None) -> Callable[[], None]:
|
|
2145
|
+
# A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
|
|
2146
|
+
# so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
|
|
2147
|
+
# The returned step undoes both when the cell ends, so a later cell never imports from that directory
|
|
2148
|
+
# ahead of the session environment; the file's own sys.path edits stay.
|
|
2149
|
+
if source_file is None:
|
|
2150
|
+
return lambda: None
|
|
2151
|
+
missing = object()
|
|
2152
|
+
previous_file = USER_NS.get("__file__", missing)
|
|
2153
|
+
directory = os.path.dirname(source_file)
|
|
2154
|
+
previous_index = sys.path.index(directory) if directory in sys.path else None
|
|
2155
|
+
with contextlib.suppress(ValueError):
|
|
2156
|
+
sys.path.remove(directory)
|
|
2157
|
+
sys.path.insert(0, directory)
|
|
2158
|
+
importlib.invalidate_caches()
|
|
2159
|
+
USER_NS["__file__"] = source_file
|
|
2160
|
+
|
|
2161
|
+
def restore() -> None:
|
|
2162
|
+
with contextlib.suppress(ValueError):
|
|
2163
|
+
sys.path.remove(directory)
|
|
2164
|
+
if previous_index is not None:
|
|
2165
|
+
sys.path.insert(min(previous_index, len(sys.path)), directory)
|
|
2166
|
+
if previous_file is missing:
|
|
2167
|
+
USER_NS.pop("__file__", None)
|
|
2168
|
+
else:
|
|
2169
|
+
USER_NS["__file__"] = previous_file
|
|
2170
|
+
# Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
|
|
2171
|
+
importlib.invalidate_caches()
|
|
2172
|
+
|
|
2173
|
+
return restore
|
|
2174
|
+
|
|
2175
|
+
|
|
2176
|
+
def run_cell(
|
|
2177
|
+
cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
|
|
2178
|
+
) -> None:
|
|
2115
2179
|
start = time.monotonic()
|
|
2116
2180
|
stdout = io.StringIO()
|
|
2117
2181
|
stderr = io.StringIO()
|
|
@@ -2119,12 +2183,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
2119
2183
|
# cells) ignores it so a late signal cannot kill the stdin-read loop.
|
|
2120
2184
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
2121
2185
|
result: dict[str, Any]
|
|
2186
|
+
leave_source_file: Callable[[], None] = lambda: None
|
|
2187
|
+
cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
|
|
2122
2188
|
try:
|
|
2123
2189
|
KERNEL_TOOL_TOKEN.acquire()
|
|
2124
2190
|
cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
|
|
2125
2191
|
with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
|
|
2126
2192
|
apply_preludes(preludes)
|
|
2127
|
-
|
|
2193
|
+
leave_source_file = _enter_source_file(source_file)
|
|
2194
|
+
body, expression = compile_cell(code, source_file or "<cell>")
|
|
2128
2195
|
LOOP.run_until_complete(run_code(body, False))
|
|
2129
2196
|
value = LOOP.run_until_complete(run_code(expression, True))
|
|
2130
2197
|
text("stdout", stdout.getvalue())
|
|
@@ -2148,7 +2215,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
|
|
|
2148
2215
|
"durationMs": elapsed(start),
|
|
2149
2216
|
}
|
|
2150
2217
|
finally:
|
|
2218
|
+
# Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
|
|
2151
2219
|
KERNEL_TOOL_TOKEN.release()
|
|
2220
|
+
CURRENT_CELL_TOKEN.reset(cell_scope)
|
|
2221
|
+
try:
|
|
2222
|
+
leave_source_file()
|
|
2223
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
|
|
2224
|
+
text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
|
|
2152
2225
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
2153
2226
|
try:
|
|
2154
2227
|
memory = KERNEL_MEMORY.after_cell()
|
|
@@ -2180,7 +2253,18 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2180
2253
|
emit({"type": "ready"})
|
|
2181
2254
|
return True
|
|
2182
2255
|
if message_type == "run":
|
|
2183
|
-
|
|
2256
|
+
env_root = message.get("envRoot")
|
|
2257
|
+
if isinstance(env_root, str):
|
|
2258
|
+
_activate_env_root(env_root)
|
|
2259
|
+
source_file = message.get("sourceFile")
|
|
2260
|
+
token = message.get("bridgeCellToken")
|
|
2261
|
+
run_cell(
|
|
2262
|
+
str(message.get("cellId", "")),
|
|
2263
|
+
str(message.get("code", "")),
|
|
2264
|
+
message.get("preludes"),
|
|
2265
|
+
source_file if isinstance(source_file, str) and source_file else None,
|
|
2266
|
+
token if isinstance(token, str) else None,
|
|
2267
|
+
)
|
|
2184
2268
|
return True
|
|
2185
2269
|
if message_type == "close":
|
|
2186
2270
|
KERNEL_TOOL_RUNNER.close()
|
|
@@ -2189,6 +2273,25 @@ def _handle_message(message: dict[str, Any]) -> bool:
|
|
|
2189
2273
|
return True
|
|
2190
2274
|
|
|
2191
2275
|
|
|
2276
|
+
_ACTIVE_ENV_ROOT: list[str] = []
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
def _activate_env_root(root: str) -> None:
|
|
2280
|
+
# One environment revision is on the import path at a time: a newer revision already holds every
|
|
2281
|
+
# package of the previous one, so it replaces that entry instead of stacking another.
|
|
2282
|
+
# An empty root means the session's current mode has nothing installed: the previous revision leaves too.
|
|
2283
|
+
wanted = [root] if root else []
|
|
2284
|
+
if _ACTIVE_ENV_ROOT == wanted:
|
|
2285
|
+
return
|
|
2286
|
+
for previous in _ACTIVE_ENV_ROOT:
|
|
2287
|
+
with contextlib.suppress(ValueError):
|
|
2288
|
+
sys.path.remove(previous)
|
|
2289
|
+
_ACTIVE_ENV_ROOT[:] = wanted
|
|
2290
|
+
if root:
|
|
2291
|
+
sys.path.insert(0, root)
|
|
2292
|
+
importlib.invalidate_caches()
|
|
2293
|
+
|
|
2294
|
+
|
|
2192
2295
|
def _terminate_process_group() -> None:
|
|
2193
2296
|
# The kernel is spawned into its own session (setsid), so its pid is its process
|
|
2194
2297
|
# group id and a cell's subprocesses inherit that group. Killing the group takes
|
|
@@ -35,6 +35,9 @@ export interface PythonTransportRunInput {
|
|
|
35
35
|
readonly code: string;
|
|
36
36
|
readonly timeoutMs?: number;
|
|
37
37
|
readonly preludePlan?: KernelPreludePlan;
|
|
38
|
+
readonly envRoot?: () => string;
|
|
39
|
+
readonly sourceFile?: string;
|
|
40
|
+
readonly bridgeCellToken?: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface PythonTransportOptions {
|
|
@@ -157,7 +160,16 @@ export class PythonKernelTransport {
|
|
|
157
160
|
install: input.preludePlan.install.map(({ exports, python }) => ({ exports: [...exports], python })),
|
|
158
161
|
remove: [...input.preludePlan.remove],
|
|
159
162
|
};
|
|
160
|
-
this.#write({
|
|
163
|
+
this.#write({
|
|
164
|
+
type: "run",
|
|
165
|
+
cellId: input.cellId,
|
|
166
|
+
code: input.code,
|
|
167
|
+
timeoutMs: input.timeoutMs,
|
|
168
|
+
preludes,
|
|
169
|
+
...(input.envRoot === undefined ? {} : { envRoot: input.envRoot() }),
|
|
170
|
+
...(input.sourceFile === undefined ? {} : { sourceFile: input.sourceFile }),
|
|
171
|
+
...(input.bridgeCellToken === undefined ? {} : { bridgeCellToken: input.bridgeCellToken }),
|
|
172
|
+
});
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
interrupt(reason: string): void {
|
|
@@ -23,9 +23,15 @@ class SenpiWorkpool
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def workpool(agent, name, mode: nil)
|
|
26
|
+
def workpool(agent, name, mode: nil, tools: nil)
|
|
27
27
|
args = { "op" => "create", "agent" => agent, "name" => name }
|
|
28
28
|
args["mode"] = mode unless mode.nil?
|
|
29
|
+
unless tools.nil?
|
|
30
|
+
unless tools.is_a?(Array) && tools.all? { |name| name.is_a?(String) }
|
|
31
|
+
raise SenpiBridgeError.new("workpool(tools: ...) takes an array of tool names; got #{tools.class}", "invalid_tools")
|
|
32
|
+
end
|
|
33
|
+
args["tools"] = tools.dup
|
|
34
|
+
end
|
|
29
35
|
result = __senpi_workpool_call(args)
|
|
30
36
|
details = result["details"]
|
|
31
37
|
if details.is_a?(Hash)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ResolvedSandbox } from "../../config/feature-settings.ts";
|
|
2
|
+
import { marshalToolResult } from "../../tool/image.ts";
|
|
3
|
+
import type { ExecuteTool, HostCellExecutor } from "../../tool/types.ts";
|
|
4
|
+
import type { CodemodeSandbox } from "./vendor/pi-codemode/runtime/host.ts";
|
|
5
|
+
import type { CodemodeError, CodemodeOutputFrame, CodemodeTool } from "./vendor/pi-codemode/types.ts";
|
|
6
|
+
|
|
7
|
+
export interface SandboxCellOptions {
|
|
8
|
+
readonly sandbox: ResolvedSandbox;
|
|
9
|
+
readonly executeTool: ExecuteTool;
|
|
10
|
+
readonly toolNames: () => readonly string[];
|
|
11
|
+
readonly describeTool?: (name: string) => string | undefined;
|
|
12
|
+
/** Where the QuickJS wasm lives; by default the installed `quickjs-wasi` package's file. */
|
|
13
|
+
readonly wasmPath?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// On the script's first line so reported line numbers still match the cell; globals, so a cell's own
|
|
17
|
+
// `const print` shadows them instead of failing as a redeclaration.
|
|
18
|
+
const OUTPUT_ALIASES =
|
|
19
|
+
'globalThis.print = (...values) => text(values.map((value) => (typeof value === "string" ? value : JSON.stringify(value))).join(" ")); globalThis.display = (value) => ((typeof value === "string" && value.startsWith("data:image/")) || (value !== null && typeof value === "object" && ("image_url" in value || value.type === "image")) ? image(value) : text(typeof value === "string" ? value : JSON.stringify(value))); globalThis.tool = tools; ';
|
|
20
|
+
|
|
21
|
+
const ERROR_CODES: Partial<Record<CodemodeError["kind"], string>> = {
|
|
22
|
+
timeout: "eval_isolate_timeout",
|
|
23
|
+
aborted: "eval_isolate_aborted",
|
|
24
|
+
sandbox: "eval_isolate_unavailable",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function codeFor(error: CodemodeError): string | undefined {
|
|
28
|
+
if (error.kind === "script" && error.reason === "memory") return "eval_isolate_memory_limit";
|
|
29
|
+
if (error.kind === "script" && error.name === "CodemodeStoreDisabledError") return "eval_isolate_no_state";
|
|
30
|
+
if (error.kind === "script" && error.reason === "unresolved") return "eval_isolate_unresolved_promise";
|
|
31
|
+
return ERROR_CODES[error.kind];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The persistent kernel never sees an isolated cell; its tools go through executeTool so permission hooks still apply. */
|
|
35
|
+
export function sandboxCellExecutor(code: string, options: SandboxCellOptions): HostCellExecutor {
|
|
36
|
+
return async ({ signal, emit }) => {
|
|
37
|
+
let sandbox: CodemodeSandbox;
|
|
38
|
+
try {
|
|
39
|
+
// Lazy: the vendored QuickJS runtime loads only when a session runs its first isolated cell. The wasm is loaded
|
|
40
|
+
// here, before any of the cell runs, so a missing runtime is reported as unavailable instead of failing inside
|
|
41
|
+
// the script.
|
|
42
|
+
const runtime = await import("./vendor/pi-codemode/runtime/host.ts");
|
|
43
|
+
const { loadQuickJSWasm } = await import("./vendor/pi-codemode/wasm.ts");
|
|
44
|
+
const wasm = await loadQuickJSWasm(options.wasmPath);
|
|
45
|
+
const items = new Map<number, string[]>();
|
|
46
|
+
const tools: CodemodeTool[] = options
|
|
47
|
+
.toolNames()
|
|
48
|
+
.filter((name) => name !== "eval")
|
|
49
|
+
.map((name) => ({
|
|
50
|
+
name,
|
|
51
|
+
description: options.describeTool?.(name) ?? "",
|
|
52
|
+
execute: async (args: unknown, context: { signal: AbortSignal }) =>
|
|
53
|
+
marshalToolResult(await options.executeTool(name, args, { signal: context.signal })),
|
|
54
|
+
}));
|
|
55
|
+
sandbox = new runtime.CodemodeSandbox({
|
|
56
|
+
wasm,
|
|
57
|
+
tools,
|
|
58
|
+
timeoutMs: options.sandbox.timeoutSeconds * 1_000,
|
|
59
|
+
memoryLimitBytes: options.sandbox.memoryMb * 1024 * 1024,
|
|
60
|
+
output: "stream",
|
|
61
|
+
builtins: { store: "reject" },
|
|
62
|
+
onOutputFrame: (frame: CodemodeOutputFrame) => {
|
|
63
|
+
const parts = items.get(frame.itemId) ?? [];
|
|
64
|
+
parts.push(frame.chunk);
|
|
65
|
+
if (!frame.final) {
|
|
66
|
+
items.set(frame.itemId, parts);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
items.delete(frame.itemId);
|
|
70
|
+
const payload = parts.join("");
|
|
71
|
+
if (frame.type === "image") {
|
|
72
|
+
emit({ type: "display", mimeType: frame.mimeType ?? "image/png", dataBase64: payload });
|
|
73
|
+
} else {
|
|
74
|
+
emit({ type: "text", stream: "stdout", data: payload.endsWith("\n") ? payload : `${payload}\n` });
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// The full error, paths included, goes to the host's own log so a maintainer can diagnose it; the cell gets
|
|
80
|
+
// only what failed and the error's code. This covers every setup step: the runtime import, the tool list,
|
|
81
|
+
// the wasm load and the sandbox itself.
|
|
82
|
+
console.error("[senpi-codemode] isolated cell setup failed:", error);
|
|
83
|
+
const code =
|
|
84
|
+
error instanceof Error && "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
error: {
|
|
88
|
+
name: "SandboxUnavailableError",
|
|
89
|
+
message: `eval_isolate_unavailable: the QuickJS runtime for isolated cells could not be loaded${code}; nothing in the cell ran`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const result = await sandbox.execute(OUTPUT_ALIASES + code, { signal });
|
|
95
|
+
if (result.ok) {
|
|
96
|
+
return result.value === undefined
|
|
97
|
+
? { ok: true }
|
|
98
|
+
: {
|
|
99
|
+
ok: true,
|
|
100
|
+
valueRepr: typeof result.value === "string" ? result.value : JSON.stringify(result.value),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const errorCode = codeFor(result.error);
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: {
|
|
107
|
+
...(result.error.name === undefined ? {} : { name: result.error.name }),
|
|
108
|
+
message: errorCode === undefined ? result.error.message : `${errorCode}: ${result.error.message}`,
|
|
109
|
+
...(result.error.stack === undefined ? {} : { stack: result.error.stack }),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
await sandbox.close();
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -40,3 +40,4 @@ A sync must re-apply both kinds: dropping a marked line (for example `"output-fr
|
|
|
40
40
|
- A normal finish settles only after every frame is consumed. An abort or timeout settles at once and drops frames still queued. The timer and the abort signal stay armed while a finished run's last frames drain, so a late abort, timeout or `close()` abandons them too (the run settles with that error): a consumer that never returns cannot keep a run alive.
|
|
41
41
|
- `frameBytes` and `windowBytes` must be at least 4, so a frame can always hold a whole surrogate pair.
|
|
42
42
|
2. **Builtin store policy** (`builtins: { store: "collect" | "reject" }`; `types.ts`, `runtime/protocol.ts`, `runtime/worker.ts`, `runtime/host.ts`, `runtime/prelude-source.ts`). With `"reject"`, `load()` returns `undefined` and `store()` throws `CodemodeStoreDisabledError`. The names stay defined, so scripts written for upstream fail with a clear message instead of a ReferenceError.
|
|
43
|
+
3. **Runtime-set error reasons** (`types.ts` `CodemodeError.reason`, `runtime/prelude-source.ts`, `runtime/worker.ts`). The prelude captures the engine's own `InternalError` before user code runs and tags a failure `reason: "memory"` only for an instance of it whose message is exactly "out of memory"; its stalled-promise failure is tagged `reason: "unresolved"`; the worker tags an engine out-of-memory from `evalCode` the same way; and `text()` rethrows the engine's own error instead of replacing it with a `TypeError`. The host maps the isolate error codes from `reason` only, never from message text. Upstream has no `reason`, so its behaviour is unchanged.
|