@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10-2

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 (60) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +103 -7
  3. package/package.json +6 -5
  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/config/executable-settings.json +5 -0
  8. package/src/config/project-trust.ts +51 -0
  9. package/src/config/settings-overrides.ts +2 -0
  10. package/src/environments/js-environments.ts +208 -0
  11. package/src/environments/js-installer.ts +268 -0
  12. package/src/environments/js-revision-files.ts +91 -0
  13. package/src/environments/no-links.ts +132 -0
  14. package/src/environments/project-resolves.ts +54 -0
  15. package/src/environments/py-installer.ts +64 -4
  16. package/src/environments/python-environments.ts +85 -0
  17. package/src/environments/revision-store.ts +38 -7
  18. package/src/extension/bridge-tool-call.ts +2 -0
  19. package/src/extension/cell-kernel-tools.ts +28 -0
  20. package/src/extension/kernel-replacement.ts +20 -0
  21. package/src/extension/runtime-factory.ts +44 -2
  22. package/src/extension/session-manager-proxy.ts +6 -0
  23. package/src/extension/session-manager.ts +18 -3
  24. package/src/index.ts +2 -0
  25. package/src/interpreters/detect.ts +34 -1
  26. package/src/kernels/jl/prelude.jl +6 -1
  27. package/src/kernels/js/cell-dispatch.ts +44 -0
  28. package/src/kernels/js/context-manager.ts +25 -15
  29. package/src/kernels/js/host-entries.ts +62 -0
  30. package/src/kernels/js/kernel-contract.ts +2 -0
  31. package/src/kernels/js/kernel-tools-registry.js +1 -0
  32. package/src/kernels/js/local-module-loader.ts +21 -5
  33. package/src/kernels/js/worker-core.js +3 -0
  34. package/src/kernels/js/worker-package-resolve.js +153 -0
  35. package/src/kernels/js/worker-runtime.js +4 -1
  36. package/src/kernels/js/worker-startup.ts +1 -0
  37. package/src/kernels/js/workpool.js +5 -2
  38. package/src/kernels/py/host-cell.ts +8 -31
  39. package/src/kernels/py/kernel.ts +8 -2
  40. package/src/kernels/py/prelude.py +122 -11
  41. package/src/kernels/py/process.ts +4 -0
  42. package/src/kernels/py/transport.ts +13 -1
  43. package/src/kernels/rb/workpool.rb +7 -1
  44. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  45. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  46. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  47. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  48. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  49. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  50. package/src/kernels/shared/host-cell.ts +57 -0
  51. package/src/prompt/eval-prompt.ts +8 -1
  52. package/src/tool/cell-handler.ts +10 -12
  53. package/src/tool/eval-request.ts +23 -1
  54. package/src/tool/eval-tool-options.ts +6 -0
  55. package/src/tool/eval-tool.ts +17 -4
  56. package/src/tool/load-cell.ts +88 -0
  57. package/src/tool/magic-cell-host.ts +123 -0
  58. package/src/tool/magic-cells.ts +75 -0
  59. package/src/tool/run-eval-cell.ts +57 -0
  60. package/src/tool/types.ts +48 -1
@@ -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):
@@ -695,7 +704,8 @@ class _Entry:
695
704
 
696
705
 
697
706
  class Registry:
698
- def __init__(self, on_change: Callable[[list[str]], None]) -> None:
707
+ def __init__(self, on_change: Callable[[list[str]], None], *, disabled: bool = False) -> None:
708
+ self._disabled = disabled
699
709
  self._lock = threading.Lock()
700
710
  self._entries: dict[str, _Entry] = {}
701
711
  # Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
@@ -704,6 +714,8 @@ class Registry:
704
714
  self.generation = 1
705
715
 
706
716
  def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
717
+ if self._disabled:
718
+ raise KernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)")
707
719
  inferred = infer_tool(fn, name=name, description=description, schema=schema)
708
720
  key = tool_key(inferred["name"])
709
721
  with self._lock:
@@ -974,7 +986,12 @@ async def _awaited(awaitable: Any) -> Any:
974
986
 
975
987
  KERNEL_TOOL_TOKEN = OwnerToken()
976
988
  KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
977
- KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
989
+ # kernelTools.enabled is read once, while the prelude loads and before any cell runs, then removed from the
990
+ # environment: rewriting os.environ cannot turn kernel tools back on, and cell subprocesses never see the switch.
991
+ KERNEL_TOOL_REGISTRY = Registry(
992
+ lambda names: emit({"type": "kernel-tools-defined", "names": names}),
993
+ disabled=os.environ.pop("SENPI_CODEMODE_KERNEL_TOOLS", None) == "0",
994
+ )
978
995
  KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
979
996
 
980
997
 
@@ -1088,10 +1105,18 @@ class Workpool:
1088
1105
  return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
1089
1106
 
1090
1107
 
1091
- def workpool(agent: dict[str, JsonValue], name: str, *, mode: str | None = None) -> Workpool:
1108
+ def workpool(
1109
+ agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
1110
+ ) -> Workpool:
1092
1111
  args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
1093
1112
  if mode is not None:
1094
1113
  args["mode"] = mode
1114
+ if tools is not None:
1115
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1116
+ raise PreludeRuntimeError(
1117
+ f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
1118
+ )
1119
+ args["tools"] = list(tools)
1095
1120
  result = _workpool_call(args)
1096
1121
  details = result.get("details")
1097
1122
  if isinstance(details, dict):
@@ -1169,9 +1194,12 @@ def agent(
1169
1194
  apply: bool | None = None,
1170
1195
  merge: bool | str | None = None,
1171
1196
  handle: bool = False,
1197
+ tools: list[str] | None = None,
1172
1198
  ) -> Any:
1173
1199
  """Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
1174
1200
 
1201
+ tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
1202
+
1175
1203
  merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
1176
1204
  changes raise an error with recovery instructions. A handle returns immediately;
1177
1205
  await the completion notification or read task_output for the isolation result.
@@ -1193,6 +1221,10 @@ def agent(
1193
1221
  args["merge"] = merge
1194
1222
  if handle:
1195
1223
  args["handle"] = True
1224
+ if tools is not None:
1225
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1226
+ raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
1227
+ args["tools"] = list(tools)
1196
1228
 
1197
1229
  response = bridge_post(
1198
1230
  "/call",
@@ -1577,8 +1609,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
1577
1609
  }
1578
1610
 
1579
1611
 
1612
+ _HOST_MAGICS = frozenset({"pip", "environment"})
1613
+
1614
+
1580
1615
  def _magic(name: str, args: str) -> Any:
1581
1616
  handler = _LINE_MAGICS.get(name)
1617
+ if handler is None and name in _HOST_MAGICS:
1618
+ # The host runs these only as a cell's first code line; anywhere else they reach here.
1619
+ raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
1582
1620
  if handler is None:
1583
1621
  raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
1584
1622
  return handler(args)
@@ -2069,8 +2107,8 @@ KERNEL_MEMORY = _KernelMemory()
2069
2107
  TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
2070
2108
 
2071
2109
 
2072
- def compile_cell(source: str) -> tuple[Any | None, Any | None]:
2073
- module = ast.parse(transform_cell(source), mode="exec")
2110
+ def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
2111
+ module = ast.parse(transform_cell(source), filename=filename, mode="exec")
2074
2112
  if not module.body:
2075
2113
  return None, None
2076
2114
  last = module.body[-1]
@@ -2078,13 +2116,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
2078
2116
  body = ast.Module(body=module.body[:-1], type_ignores=[])
2079
2117
  expression = ast.Expression(body=last.value)
2080
2118
  ast.copy_location(expression, last)
2081
- return compile(body, "<cell>", "exec", flags=TLA_FLAG), compile(
2119
+ return compile(body, filename, "exec", flags=TLA_FLAG), compile(
2082
2120
  expression,
2083
- "<cell>",
2121
+ filename,
2084
2122
  "eval",
2085
2123
  flags=TLA_FLAG,
2086
2124
  )
2087
- return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
2125
+ return compile(module, filename, "exec", flags=TLA_FLAG), None
2088
2126
 
2089
2127
 
2090
2128
  async def run_code(code: Any, want_value: bool) -> Any:
@@ -2111,7 +2149,41 @@ def apply_preludes(preludes: Any) -> None:
2111
2149
  exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
2112
2150
 
2113
2151
 
2114
- def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2152
+ def _enter_source_file(source_file: str | None) -> Callable[[], None]:
2153
+ # A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
2154
+ # so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
2155
+ # The returned step undoes both when the cell ends, so a later cell never imports from that directory
2156
+ # ahead of the session environment; the file's own sys.path edits stay.
2157
+ if source_file is None:
2158
+ return lambda: None
2159
+ missing = object()
2160
+ previous_file = USER_NS.get("__file__", missing)
2161
+ directory = os.path.dirname(source_file)
2162
+ previous_index = sys.path.index(directory) if directory in sys.path else None
2163
+ with contextlib.suppress(ValueError):
2164
+ sys.path.remove(directory)
2165
+ sys.path.insert(0, directory)
2166
+ importlib.invalidate_caches()
2167
+ USER_NS["__file__"] = source_file
2168
+
2169
+ def restore() -> None:
2170
+ with contextlib.suppress(ValueError):
2171
+ sys.path.remove(directory)
2172
+ if previous_index is not None:
2173
+ sys.path.insert(min(previous_index, len(sys.path)), directory)
2174
+ if previous_file is missing:
2175
+ USER_NS.pop("__file__", None)
2176
+ else:
2177
+ USER_NS["__file__"] = previous_file
2178
+ # Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
2179
+ importlib.invalidate_caches()
2180
+
2181
+ return restore
2182
+
2183
+
2184
+ def run_cell(
2185
+ cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
2186
+ ) -> None:
2115
2187
  start = time.monotonic()
2116
2188
  stdout = io.StringIO()
2117
2189
  stderr = io.StringIO()
@@ -2119,12 +2191,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2119
2191
  # cells) ignores it so a late signal cannot kill the stdin-read loop.
2120
2192
  signal.signal(signal.SIGINT, signal.default_int_handler)
2121
2193
  result: dict[str, Any]
2194
+ leave_source_file: Callable[[], None] = lambda: None
2195
+ cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
2122
2196
  try:
2123
2197
  KERNEL_TOOL_TOKEN.acquire()
2124
2198
  cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2125
2199
  with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
2126
2200
  apply_preludes(preludes)
2127
- body, expression = compile_cell(code)
2201
+ leave_source_file = _enter_source_file(source_file)
2202
+ body, expression = compile_cell(code, source_file or "<cell>")
2128
2203
  LOOP.run_until_complete(run_code(body, False))
2129
2204
  value = LOOP.run_until_complete(run_code(expression, True))
2130
2205
  text("stdout", stdout.getvalue())
@@ -2148,7 +2223,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2148
2223
  "durationMs": elapsed(start),
2149
2224
  }
2150
2225
  finally:
2226
+ # Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
2151
2227
  KERNEL_TOOL_TOKEN.release()
2228
+ CURRENT_CELL_TOKEN.reset(cell_scope)
2229
+ try:
2230
+ leave_source_file()
2231
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
2232
+ text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
2152
2233
  signal.signal(signal.SIGINT, signal.SIG_IGN)
2153
2234
  try:
2154
2235
  memory = KERNEL_MEMORY.after_cell()
@@ -2180,7 +2261,18 @@ def _handle_message(message: dict[str, Any]) -> bool:
2180
2261
  emit({"type": "ready"})
2181
2262
  return True
2182
2263
  if message_type == "run":
2183
- run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
2264
+ env_root = message.get("envRoot")
2265
+ if isinstance(env_root, str):
2266
+ _activate_env_root(env_root)
2267
+ source_file = message.get("sourceFile")
2268
+ token = message.get("bridgeCellToken")
2269
+ run_cell(
2270
+ str(message.get("cellId", "")),
2271
+ str(message.get("code", "")),
2272
+ message.get("preludes"),
2273
+ source_file if isinstance(source_file, str) and source_file else None,
2274
+ token if isinstance(token, str) else None,
2275
+ )
2184
2276
  return True
2185
2277
  if message_type == "close":
2186
2278
  KERNEL_TOOL_RUNNER.close()
@@ -2189,6 +2281,25 @@ def _handle_message(message: dict[str, Any]) -> bool:
2189
2281
  return True
2190
2282
 
2191
2283
 
2284
+ _ACTIVE_ENV_ROOT: list[str] = []
2285
+
2286
+
2287
+ def _activate_env_root(root: str) -> None:
2288
+ # One environment revision is on the import path at a time: a newer revision already holds every
2289
+ # package of the previous one, so it replaces that entry instead of stacking another.
2290
+ # An empty root means the session's current mode has nothing installed: the previous revision leaves too.
2291
+ wanted = [root] if root else []
2292
+ if _ACTIVE_ENV_ROOT == wanted:
2293
+ return
2294
+ for previous in _ACTIVE_ENV_ROOT:
2295
+ with contextlib.suppress(ValueError):
2296
+ sys.path.remove(previous)
2297
+ _ACTIVE_ENV_ROOT[:] = wanted
2298
+ if root:
2299
+ sys.path.insert(0, root)
2300
+ importlib.invalidate_caches()
2301
+
2302
+
2192
2303
  def _terminate_process_group() -> None:
2193
2304
  # The kernel is spawned into its own session (setsid), so its pid is its process
2194
2305
  # group id and a cell's subprocesses inherit that group. Killing the group takes
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute } from "node:path";
2
4
  import type { Readable, Writable } from "node:stream";
3
5
 
4
6
  export interface KernelChild {
@@ -53,6 +55,8 @@ export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
53
55
  }
54
56
 
55
57
  export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
58
+ // A configured interpreter path is one executable even when it contains spaces.
59
+ if (isAbsolute(commandLine) && existsSync(commandLine)) return { command: commandLine, args: [] };
56
60
  const [command, ...args] = commandLine.split(" ").filter(Boolean);
57
61
  if (!command) throw new Error("Python interpreter path is empty");
58
62
  return { command, args };
@@ -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.
@@ -46,6 +46,10 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
46
46
  const options = optionsJson === undefined ? {} : JSON.parse(optionsJson);
47
47
  const streamOutput = options.streamOutput === true;
48
48
  const rejectStore = options.store === "reject";
49
+ // The engine's own InternalError, captured before user code runs: a failure is tagged by identity, never by its
50
+ // text, so a cell's own error that merely mentions memory keeps its own code. A script can still construct this
51
+ // constructor itself and so label its own failure as the memory limit; that mislabels only its own cell.
52
+ const EngineInternalError = typeof InternalError === "function" ? InternalError : undefined;
49
53
  // senpi-change end
50
54
  "use strict";
51
55
  const stringify = JSON.stringify;
@@ -94,7 +98,13 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
94
98
 
95
99
  function describeError(error) {
96
100
  if (error instanceof ErrorCtor) {
97
- return stringify({ name: error.name, message: error.message, stack: errorText(error) });
101
+ // senpi-change begin: the engine's out-of-memory error is reported by identity, not by matching its message
102
+ const reason =
103
+ EngineInternalError !== undefined && error instanceof EngineInternalError && error.message === "out of memory"
104
+ ? "memory"
105
+ : undefined;
106
+ return stringify({ name: error.name, message: error.message, stack: errorText(error), reason });
107
+ // senpi-change end
98
108
  }
99
109
  return stringify({ message: format(error) });
100
110
  }
@@ -285,6 +295,8 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
285
295
  try {
286
296
  rendered = outputText(value);
287
297
  } catch (error) {
298
+ // The engine's own out-of-memory error keeps its identity, so it is still reported as the memory limit.
299
+ if (EngineInternalError !== undefined && error instanceof EngineInternalError) throw error; // senpi-change
288
300
  throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
289
301
  }
290
302
  output("text", rendered);
@@ -423,6 +435,7 @@ export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson
423
435
  name: "Error",
424
436
  message:
425
437
  "The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
438
+ reason: "unresolved", // senpi-change: tagged by the runtime, so a script's own error text never matches it
426
439
  }),
427
440
  );
428
441
  return true;
@@ -48,7 +48,10 @@ function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
48
48
  function describeException(error: JSException): string {
49
49
  const head = error.message ? `${error.name}: ${error.message}` : error.name;
50
50
  const stack = error.stack?.trimEnd();
51
- return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
51
+ // senpi-change begin: evalCode fails before any script runs, so an InternalError here is the engine's own
52
+ const reason = error.name === "InternalError" && error.message === "out of memory" ? "memory" : undefined;
53
+ return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head, reason });
54
+ // senpi-change end
52
55
  }
53
56
  // senpi-change begin: output streaming
54
57
  /**
@@ -71,6 +71,8 @@ export interface CodemodeError {
71
71
  name?: string;
72
72
  message: string;
73
73
  stack?: string;
74
+ /** senpi-change: set by the runtime itself (never from script text) for its own memory-limit and stalled failures. */
75
+ reason?: "memory" | "unresolved";
74
76
  }
75
77
 
76
78
  /** Keys the script changed with `store()`. Only successful executions report writes. */
@@ -0,0 +1,18 @@
1
+ import type { CellSourceAtStart, EvalKernelRunInput } from "../../tool/types.ts";
2
+
3
+ /** The run input with its turn-time source applied, or the refusal to settle the cell with. */
4
+ export function inputAtStart<T extends EvalKernelRunInput>(input: T): T | { readonly refused: string } {
5
+ if (input.resolveAtStart === undefined) return input;
6
+ let source: CellSourceAtStart;
7
+ try {
8
+ source = input.resolveAtStart();
9
+ } catch (error) {
10
+ return { refused: error instanceof Error ? error.message : String(error) };
11
+ }
12
+ if (!source.ok) return { refused: source.message };
13
+ return {
14
+ ...input,
15
+ code: source.code,
16
+ ...(source.sourceFile === undefined ? {} : { sourceFile: source.sourceFile }),
17
+ };
18
+ }
@@ -0,0 +1,57 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { HostCellExecutor } from "../../tool/types.ts";
3
+
4
+ type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
5
+ type HostResult = Extract<KernelToHostMessage, { type: "result" }>;
6
+
7
+ export interface HostCellRun {
8
+ readonly cellId: string;
9
+ readonly abort: AbortController;
10
+ readonly done: Promise<HostResult>;
11
+ }
12
+
13
+ /**
14
+ * Runs a host entry (a cell the host executes, such as an install) for one queue slot: output after the outcome or after
15
+ * an abort belongs to no cell, a synchronous throw stays in this cell, and `settle` receives the cell's result.
16
+ * `settleAfterAbort` says whether an aborted entry still settles here or whoever aborted it settles the cell.
17
+ */
18
+ export function runHostCell(
19
+ cellId: string,
20
+ host: HostCellExecutor,
21
+ io: {
22
+ readonly emit: (message: KernelToHostMessage) => void;
23
+ readonly settle: (result: HostResult) => void;
24
+ readonly durationMs: () => number;
25
+ readonly settleAfterAbort: boolean;
26
+ },
27
+ ): HostCellRun {
28
+ const abort = new AbortController();
29
+ let finished = false;
30
+ const emit = (message: KernelToHostMessage): void => {
31
+ if (!finished && !abort.signal.aborted) io.emit(message);
32
+ };
33
+ const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
34
+ (outcome): HostResult =>
35
+ outcome.ok
36
+ ? {
37
+ type: "result",
38
+ cellId,
39
+ ok: true,
40
+ durationMs: io.durationMs(),
41
+ ...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
42
+ }
43
+ : { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
44
+ (error: unknown): HostResult => ({
45
+ type: "result",
46
+ cellId,
47
+ ok: false,
48
+ error: { message: error instanceof Error ? error.message : String(error) },
49
+ durationMs: 0,
50
+ }),
51
+ );
52
+ void done.then((result) => {
53
+ finished = true;
54
+ if (io.settleAfterAbort || !abort.signal.aborted) io.settle(result);
55
+ });
56
+ return { cellId, abort, done };
57
+ }
@@ -18,6 +18,8 @@ export interface EvalPromptParts {
18
18
 
19
19
  export interface EvalPromptOptions {
20
20
  readonly spawns: boolean;
21
+ /** `prompt.advertiseHelpers`: true appends one pointer line to the helper documentation entry. */
22
+ readonly advertiseHelpers?: boolean;
21
23
  /** Whether the session registry exposes the monitor tool through eval. */
22
24
  readonly monitor?: boolean;
23
25
  readonly spawnDefaultAgent?: string;
@@ -74,6 +76,10 @@ export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyl
74
76
  type ContextValue = string | boolean;
75
77
  type Context = Readonly<Record<string, ContextValue>>;
76
78
 
79
+ /** The single line `prompt.advertiseHelpers: true` adds; off by default, so the default description is unchanged. */
80
+ export const ADVERTISED_HELPERS_LINE =
81
+ "Advanced cell helpers (handle controls, wait(), kernel tools in workpools) are documented on demand: tool_schema('eval:helpers').";
82
+
77
83
  export function buildEvalPrompt(
78
84
  enabled: EnabledLanguages,
79
85
  options: EvalPromptOptions = { spawns: false },
@@ -104,9 +110,10 @@ export function buildEvalPrompt(
104
110
  maxDetachedCells: String(options.maxDetachedCells ?? DEFAULT_MAX_DETACHED_CELLS),
105
111
  kernelPreludeDocs: (options.kernelPreludes ?? []).map((prelude) => prelude.documentation).join("\n"),
106
112
  };
107
- const description = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
113
+ const rendered = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
108
114
  .replace(/\n{3,}/g, "\n\n")
109
115
  .trim();
116
+ const description = options.advertiseHelpers === true ? `${rendered}\n\n${ADVERTISED_HELPERS_LINE}` : rendered;
110
117
  return {
111
118
  description,
112
119
  promptSnippet: "Run one incremental code cell in a persistent language kernel.",
@@ -48,7 +48,7 @@ export interface CellBridgeRuntime {
48
48
  readonly ctx: ExtensionContext;
49
49
  readonly artifactPath?: string;
50
50
  readonly imageResizer?: EvalImageResizer;
51
- /** This cell's live kernel-tool capability; only a JS kernel has one (#1754). */
51
+ /** This cell's live kernel-tool capability; JS and Python kernels have one (#1754, #2731). */
52
52
  readonly kernelTools?: KernelToolsCapability;
53
53
  /** The session generation's handle registry behind `wait()` / `handle()` / completion handles. */
54
54
  readonly handles?: HandleRegistry;
@@ -235,22 +235,20 @@ export class CellHandler {
235
235
  this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
236
236
  } catch (error) {
237
237
  if (!this.#state.active) return;
238
- const text = appendSchemaHint(
239
- error instanceof Error ? error.message : String(error),
240
- message.toolName,
241
- this.#toolParameters(message.toolName),
242
- );
238
+ const code =
239
+ error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined;
240
+ const errorMessage = error instanceof Error ? error.message : String(error);
241
+ // A blocked call (a permission denial or another hook's veto) is not an argument problem.
242
+ const text =
243
+ code === "blocked"
244
+ ? errorMessage
245
+ : appendSchemaHint(errorMessage, message.toolName, this.#toolParameters(message.toolName));
243
246
  recordToolCall(this.#state.toolCalls, false, capture, undefined, text);
244
247
  this.#kernel.deliverToolReply({
245
248
  type: "tool-reply",
246
249
  callId: message.callId,
247
250
  ok: false,
248
- error: {
249
- message: text,
250
- ...(error instanceof Error && "code" in error && typeof error.code === "string"
251
- ? { code: error.code }
252
- : {}),
253
- },
251
+ error: { message: text, ...(code === undefined ? {} : { code }) },
254
252
  });
255
253
  }
256
254
  this.#resultBuilder.emitUpdate(false);