@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +100 -4
  3. package/package.json +4 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/protocol.ts +4 -0
  6. package/src/bridges/eval-virtual-schemas.ts +1 -0
  7. package/src/environments/js-environments.ts +208 -0
  8. package/src/environments/js-installer.ts +268 -0
  9. package/src/environments/js-revision-files.ts +91 -0
  10. package/src/environments/no-links.ts +132 -0
  11. package/src/environments/project-resolves.ts +54 -0
  12. package/src/environments/py-installer.ts +64 -4
  13. package/src/environments/python-environments.ts +85 -0
  14. package/src/environments/revision-store.ts +38 -7
  15. package/src/extension/bridge-tool-call.ts +2 -0
  16. package/src/extension/cell-kernel-tools.ts +28 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +21 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +10 -2
  21. package/src/index.ts +2 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/cell-dispatch.ts +44 -0
  24. package/src/kernels/js/context-manager.ts +25 -15
  25. package/src/kernels/js/host-entries.ts +62 -0
  26. package/src/kernels/js/local-module-loader.ts +21 -5
  27. package/src/kernels/js/worker-core.js +2 -0
  28. package/src/kernels/js/worker-package-resolve.js +153 -0
  29. package/src/kernels/js/worker-runtime.js +3 -1
  30. package/src/kernels/js/workpool.js +5 -2
  31. package/src/kernels/py/host-cell.ts +8 -31
  32. package/src/kernels/py/kernel.ts +8 -2
  33. package/src/kernels/py/prelude.py +112 -9
  34. package/src/kernels/py/transport.ts +13 -1
  35. package/src/kernels/rb/workpool.rb +7 -1
  36. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  37. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  39. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  40. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  41. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  42. package/src/kernels/shared/host-cell.ts +57 -0
  43. package/src/tool/cell-handler.ts +10 -12
  44. package/src/tool/eval-request.ts +23 -1
  45. package/src/tool/eval-tool-options.ts +6 -0
  46. package/src/tool/eval-tool.ts +16 -4
  47. package/src/tool/load-cell.ts +88 -0
  48. package/src/tool/magic-cell-host.ts +123 -0
  49. package/src/tool/magic-cells.ts +75 -0
  50. package/src/tool/run-eval-cell.ts +57 -0
  51. 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)), cellId));
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 abort = new AbortController();
15
- pending.hostAbort = abort;
16
- const cellId = pending.input.cellId;
17
- let finished = false;
18
- // Output from an executor that was aborted, or after its outcome, belongs to no cell.
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
  }
@@ -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
- ...pending.input,
249
- preludePlan: this.#preludes.plan(pending.input.kernelPreludes ?? []),
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(agent: dict[str, JsonValue], name: str, *, mode: str | None = None) -> 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, "<cell>", "exec", flags=TLA_FLAG), compile(
2111
+ return compile(body, filename, "exec", flags=TLA_FLAG), compile(
2082
2112
  expression,
2083
- "<cell>",
2113
+ filename,
2084
2114
  "eval",
2085
2115
  flags=TLA_FLAG,
2086
2116
  )
2087
- return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
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 run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
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
- body, expression = compile_cell(code)
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
- run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
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({ type: "run", cellId: input.cellId, code: input.code, timeoutMs: input.timeoutMs, preludes });
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.