@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.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 (106) hide show
  1. package/CHANGELOG.md +178 -0
  2. package/README.md +212 -14
  3. package/package.json +6 -5
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/protocol.ts +12 -3
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +48 -0
  8. package/src/bridges/eval-virtual-schemas.ts +12 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/js-environments.ts +208 -0
  14. package/src/environments/js-installer.ts +268 -0
  15. package/src/environments/js-revision-files.ts +91 -0
  16. package/src/environments/no-links.ts +132 -0
  17. package/src/environments/packages-install.ts +98 -0
  18. package/src/environments/project-resolves.ts +54 -0
  19. package/src/environments/py-environment.ts +7 -3
  20. package/src/environments/py-installer.ts +68 -4
  21. package/src/environments/python-environments.ts +85 -0
  22. package/src/environments/revision-store.ts +38 -7
  23. package/src/extension/bridge-tool-call.ts +7 -1
  24. package/src/extension/cell-kernel-tools.ts +28 -0
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/kernel-replacement.ts +20 -0
  28. package/src/extension/runtime-factory.ts +53 -3
  29. package/src/extension/runtime-info.ts +6 -2
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager-proxy.ts +6 -0
  32. package/src/extension/session-manager.ts +26 -4
  33. package/src/index.ts +4 -2
  34. package/src/interpreters/detect.ts +34 -1
  35. package/src/kernels/AGENTS.md +2 -0
  36. package/src/kernels/jl/prelude.jl +6 -1
  37. package/src/kernels/js/cell-dispatch.ts +44 -0
  38. package/src/kernels/js/cell-ownership.js +268 -0
  39. package/src/kernels/js/cell-run-context.js +89 -0
  40. package/src/kernels/js/context-manager.ts +48 -39
  41. package/src/kernels/js/control-frames.ts +24 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/kernel-contract.ts +16 -1
  44. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  45. package/src/kernels/js/kernel-memory.ts +79 -0
  46. package/src/kernels/js/kernel-tools-registry.js +1 -0
  47. package/src/kernels/js/local-module-loader.ts +21 -5
  48. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  49. package/src/kernels/js/process-drain-scan.js +28 -0
  50. package/src/kernels/js/process-entry.js +466 -0
  51. package/src/kernels/js/process-memory-host.ts +83 -0
  52. package/src/kernels/js/process-worker.ts +298 -0
  53. package/src/kernels/js/rejection-reports.js +61 -0
  54. package/src/kernels/js/run-queue.ts +7 -0
  55. package/src/kernels/js/worker-core.js +53 -8
  56. package/src/kernels/js/worker-cwd.js +5 -2
  57. package/src/kernels/js/worker-host.ts +9 -2
  58. package/src/kernels/js/worker-indirect-eval.js +37 -13
  59. package/src/kernels/js/worker-package-resolve.js +153 -0
  60. package/src/kernels/js/worker-require.d.ts +19 -0
  61. package/src/kernels/js/worker-require.js +52 -0
  62. package/src/kernels/js/worker-runtime.js +45 -9
  63. package/src/kernels/js/worker-shadow-guard.js +2 -6
  64. package/src/kernels/js/worker-shell-capture.js +5 -0
  65. package/src/kernels/js/worker-slot.ts +9 -0
  66. package/src/kernels/js/worker-startup.ts +49 -1
  67. package/src/kernels/js/worker-webview.js +15 -1
  68. package/src/kernels/js/workpool.js +5 -2
  69. package/src/kernels/py/host-cell.ts +8 -31
  70. package/src/kernels/py/kernel.ts +8 -2
  71. package/src/kernels/py/prelude.py +145 -11
  72. package/src/kernels/py/process.ts +4 -0
  73. package/src/kernels/py/transport.ts +13 -1
  74. package/src/kernels/rb/workpool.rb +7 -1
  75. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  76. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  77. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  78. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  79. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  80. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  81. package/src/kernels/shared/host-cell.ts +57 -0
  82. package/src/kernels/shared/subprocess-process.ts +14 -0
  83. package/src/prompt/eval-prompt.ts +8 -1
  84. package/src/tool/AGENTS.md +1 -0
  85. package/src/tool/cell-handler.ts +13 -12
  86. package/src/tool/cell-reserved-dispatch.ts +1 -0
  87. package/src/tool/cell-runtime.ts +4 -2
  88. package/src/tool/detached-cell-contract.ts +3 -0
  89. package/src/tool/detached-cell-manager.ts +4 -8
  90. package/src/tool/detached-cell-notification.ts +11 -63
  91. package/src/tool/detached-cell-snapshot.ts +17 -0
  92. package/src/tool/detached-eval-result.ts +13 -7
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-request.ts +23 -1
  95. package/src/tool/eval-tool-options.ts +6 -0
  96. package/src/tool/eval-tool.ts +17 -4
  97. package/src/tool/interrupt-note.ts +4 -5
  98. package/src/tool/live-headline.ts +60 -0
  99. package/src/tool/load-cell.ts +88 -0
  100. package/src/tool/magic-cell-host.ts +123 -0
  101. package/src/tool/magic-cells.ts +75 -0
  102. package/src/tool/managed-cell.ts +0 -4
  103. package/src/tool/render.ts +74 -15
  104. package/src/tool/run-eval-cell.ts +73 -1
  105. package/src/tool/runtime-label.ts +2 -1
  106. package/src/tool/types.ts +50 -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
@@ -60,6 +61,7 @@ RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
60
61
  RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
61
62
  RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
62
63
  RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
64
+ RESERVED_PACKAGES_INSTALL_TOOL = "__packages_install__"
63
65
  TIMEOUT_PAUSE_OP = "timeout-pause"
64
66
  TIMEOUT_RESUME_OP = "timeout-resume"
65
67
 
@@ -374,7 +376,15 @@ _BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
374
376
  _WAIT_SOCKET_GRACE_SECONDS = 30
375
377
 
376
378
 
379
+ # The host's secret for the run in this context. Every host call carries it, so the host gives the call that run's
380
+ # kernel tools; copied contexts (parallel workers) keep it, plain threads do not, and the host forgets it at settle.
381
+ CURRENT_CELL_TOKEN: contextvars.ContextVar[str | None] = contextvars.ContextVar("senpi_cell_token", default=None)
382
+
383
+
377
384
  def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
385
+ cell_token = CURRENT_CELL_TOKEN.get()
386
+ if path == "/call" and cell_token is not None and "cellToken" not in payload:
387
+ payload = {**payload, "cellToken": cell_token}
378
388
  port = CONNECTION.get("port")
379
389
  token = CONNECTION.get("token")
380
390
  if not isinstance(port, int) or not isinstance(token, str):
@@ -695,7 +705,8 @@ class _Entry:
695
705
 
696
706
 
697
707
  class Registry:
698
- def __init__(self, on_change: Callable[[list[str]], None]) -> None:
708
+ def __init__(self, on_change: Callable[[list[str]], None], *, disabled: bool = False) -> None:
709
+ self._disabled = disabled
699
710
  self._lock = threading.Lock()
700
711
  self._entries: dict[str, _Entry] = {}
701
712
  # Revisions outlive undefine: a redefined name never reuses a revision an old descriptor carries.
@@ -704,6 +715,8 @@ class Registry:
704
715
  self.generation = 1
705
716
 
706
717
  def define(self, fn: Any, *, name: Any = None, description: Any = None, schema: Any = None) -> Any:
718
+ if self._disabled:
719
+ raise KernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)")
707
720
  inferred = infer_tool(fn, name=name, description=description, schema=schema)
708
721
  key = tool_key(inferred["name"])
709
722
  with self._lock:
@@ -974,7 +987,12 @@ async def _awaited(awaitable: Any) -> Any:
974
987
 
975
988
  KERNEL_TOOL_TOKEN = OwnerToken()
976
989
  KERNEL_TOOL_STREAMS = (ThreadRoutedStream(stderr=False), ThreadRoutedStream(stderr=True))
977
- KERNEL_TOOL_REGISTRY = Registry(lambda names: emit({"type": "kernel-tools-defined", "names": names}))
990
+ # kernelTools.enabled is read once, while the prelude loads and before any cell runs, then removed from the
991
+ # environment: rewriting os.environ cannot turn kernel tools back on, and cell subprocesses never see the switch.
992
+ KERNEL_TOOL_REGISTRY = Registry(
993
+ lambda names: emit({"type": "kernel-tools-defined", "names": names}),
994
+ disabled=os.environ.pop("SENPI_CODEMODE_KERNEL_TOOLS", None) == "0",
995
+ )
978
996
  KERNEL_TOOL_RUNNER = KernelToolRunner(emit, KERNEL_TOOL_TOKEN, KERNEL_TOOL_REGISTRY, KERNEL_TOOL_STREAMS)
979
997
 
980
998
 
@@ -1088,10 +1106,18 @@ class Workpool:
1088
1106
  return _workpool_call({"op": "cancel", "pool_id": self.pool_id})
1089
1107
 
1090
1108
 
1091
- def workpool(agent: dict[str, JsonValue], name: str, *, mode: str | None = None) -> Workpool:
1109
+ def workpool(
1110
+ agent: dict[str, JsonValue], name: str, *, mode: str | None = None, tools: list[str] | None = None
1111
+ ) -> Workpool:
1092
1112
  args: dict[str, JsonValue] = {"op": "create", "agent": agent, "name": name}
1093
1113
  if mode is not None:
1094
1114
  args["mode"] = mode
1115
+ if tools is not None:
1116
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1117
+ raise PreludeRuntimeError(
1118
+ f"workpool(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools"
1119
+ )
1120
+ args["tools"] = list(tools)
1095
1121
  result = _workpool_call(args)
1096
1122
  details = result.get("details")
1097
1123
  if isinstance(details, dict):
@@ -1137,6 +1163,27 @@ def tool_schema(name: str | None = None) -> Any:
1137
1163
  )
1138
1164
 
1139
1165
 
1166
+ class _Packages:
1167
+ """packages.install(manager, requirements, *, timeout=600): the %pip installer as a call; returns its receipt."""
1168
+
1169
+ __slots__ = ()
1170
+
1171
+ def install(self, manager: str, requirements: Any, *, timeout: float | None = None) -> Any:
1172
+ args: dict[str, Any] = {"manager": manager, "requirements": requirements}
1173
+ if timeout is not None:
1174
+ args["timeout"] = timeout
1175
+ # A long install must not hit the 60 s socket cap; the host bounds it by the timeout (600 s by default).
1176
+ socket_timeout = (600 if timeout is None else float(timeout)) + _WAIT_SOCKET_GRACE_SECONDS
1177
+ return bridge_post(
1178
+ "/call",
1179
+ {"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_PACKAGES_INSTALL_TOOL, "args": args},
1180
+ socket_timeout=socket_timeout,
1181
+ )
1182
+
1183
+
1184
+ packages = _Packages()
1185
+
1186
+
1140
1187
  def output(
1141
1188
  *ids: str,
1142
1189
  format: str = "raw",
@@ -1169,9 +1216,12 @@ def agent(
1169
1216
  apply: bool | None = None,
1170
1217
  merge: bool | str | None = None,
1171
1218
  handle: bool = False,
1219
+ tools: list[str] | None = None,
1172
1220
  ) -> Any:
1173
1221
  """Delegate work; isolated/apply/merge need a host that supports isolation, otherwise a warning.
1174
1222
 
1223
+ tools grants the child this kernel's @tool functions by name, as JavaScript's agent(prompt, { tools }) does.
1224
+
1175
1225
  merge accepts "patch"/"branch" or False/True respectively. Unapplied foreground
1176
1226
  changes raise an error with recovery instructions. A handle returns immediately;
1177
1227
  await the completion notification or read task_output for the isolation result.
@@ -1193,6 +1243,10 @@ def agent(
1193
1243
  args["merge"] = merge
1194
1244
  if handle:
1195
1245
  args["handle"] = True
1246
+ if tools is not None:
1247
+ if not isinstance(tools, (list, tuple)) or not all(isinstance(name, str) for name in tools):
1248
+ raise PreludeRuntimeError(f"agent(tools=...) takes a list of tool names; got {type(tools).__name__}", "invalid_tools")
1249
+ args["tools"] = list(tools)
1196
1250
 
1197
1251
  response = bridge_post(
1198
1252
  "/call",
@@ -1577,8 +1631,14 @@ _LINE_MAGICS: dict[str, Callable[[str], Any]] = {
1577
1631
  }
1578
1632
 
1579
1633
 
1634
+ _HOST_MAGICS = frozenset({"pip", "environment"})
1635
+
1636
+
1580
1637
  def _magic(name: str, args: str) -> Any:
1581
1638
  handler = _LINE_MAGICS.get(name)
1639
+ if handler is None and name in _HOST_MAGICS:
1640
+ # The host runs these only as a cell's first code line; anywhere else they reach here.
1641
+ raise PreludeRuntimeError(f"put %{name} on its own cell, then run the code that uses it in the next cell")
1582
1642
  if handler is None:
1583
1643
  raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
1584
1644
  return handler(args)
@@ -1751,6 +1811,7 @@ USER_NS.update(
1751
1811
  "workpool": workpool,
1752
1812
  "output": output,
1753
1813
  "tool_schema": tool_schema,
1814
+ "packages": packages,
1754
1815
  "wait": wait,
1755
1816
  "handle": handle,
1756
1817
  "__senpi_magic": _magic,
@@ -2069,8 +2130,8 @@ KERNEL_MEMORY = _KernelMemory()
2069
2130
  TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
2070
2131
 
2071
2132
 
2072
- def compile_cell(source: str) -> tuple[Any | None, Any | None]:
2073
- module = ast.parse(transform_cell(source), mode="exec")
2133
+ def compile_cell(source: str, filename: str = "<cell>") -> tuple[Any | None, Any | None]:
2134
+ module = ast.parse(transform_cell(source), filename=filename, mode="exec")
2074
2135
  if not module.body:
2075
2136
  return None, None
2076
2137
  last = module.body[-1]
@@ -2078,13 +2139,13 @@ def compile_cell(source: str) -> tuple[Any | None, Any | None]:
2078
2139
  body = ast.Module(body=module.body[:-1], type_ignores=[])
2079
2140
  expression = ast.Expression(body=last.value)
2080
2141
  ast.copy_location(expression, last)
2081
- return compile(body, "<cell>", "exec", flags=TLA_FLAG), compile(
2142
+ return compile(body, filename, "exec", flags=TLA_FLAG), compile(
2082
2143
  expression,
2083
- "<cell>",
2144
+ filename,
2084
2145
  "eval",
2085
2146
  flags=TLA_FLAG,
2086
2147
  )
2087
- return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
2148
+ return compile(module, filename, "exec", flags=TLA_FLAG), None
2088
2149
 
2089
2150
 
2090
2151
  async def run_code(code: Any, want_value: bool) -> Any:
@@ -2111,7 +2172,41 @@ def apply_preludes(preludes: Any) -> None:
2111
2172
  exec(compile(contribution.get("python", ""), "<kernel-prelude>", "exec"), USER_NS)
2112
2173
 
2113
2174
 
2114
- def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2175
+ def _enter_source_file(source_file: str | None) -> Callable[[], None]:
2176
+ # A %load cell runs as its file: __file__ names it and its directory comes first on the import path,
2177
+ # so `from sibling import x` resolves next to the file the way it does when the file runs as a script.
2178
+ # The returned step undoes both when the cell ends, so a later cell never imports from that directory
2179
+ # ahead of the session environment; the file's own sys.path edits stay.
2180
+ if source_file is None:
2181
+ return lambda: None
2182
+ missing = object()
2183
+ previous_file = USER_NS.get("__file__", missing)
2184
+ directory = os.path.dirname(source_file)
2185
+ previous_index = sys.path.index(directory) if directory in sys.path else None
2186
+ with contextlib.suppress(ValueError):
2187
+ sys.path.remove(directory)
2188
+ sys.path.insert(0, directory)
2189
+ importlib.invalidate_caches()
2190
+ USER_NS["__file__"] = source_file
2191
+
2192
+ def restore() -> None:
2193
+ with contextlib.suppress(ValueError):
2194
+ sys.path.remove(directory)
2195
+ if previous_index is not None:
2196
+ sys.path.insert(min(previous_index, len(sys.path)), directory)
2197
+ if previous_file is missing:
2198
+ USER_NS.pop("__file__", None)
2199
+ else:
2200
+ USER_NS["__file__"] = previous_file
2201
+ # Last: it calls every sys.meta_path finder, and user code may have installed one that raises.
2202
+ importlib.invalidate_caches()
2203
+
2204
+ return restore
2205
+
2206
+
2207
+ def run_cell(
2208
+ cell_id: str, code: str, preludes: Any = None, source_file: str | None = None, cell_token: str | None = None
2209
+ ) -> None:
2115
2210
  start = time.monotonic()
2116
2211
  stdout = io.StringIO()
2117
2212
  stderr = io.StringIO()
@@ -2119,12 +2214,15 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2119
2214
  # cells) ignores it so a late signal cannot kill the stdin-read loop.
2120
2215
  signal.signal(signal.SIGINT, signal.default_int_handler)
2121
2216
  result: dict[str, Any]
2217
+ leave_source_file: Callable[[], None] = lambda: None
2218
+ cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
2122
2219
  try:
2123
2220
  KERNEL_TOOL_TOKEN.acquire()
2124
2221
  cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2125
2222
  with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
2126
2223
  apply_preludes(preludes)
2127
- body, expression = compile_cell(code)
2224
+ leave_source_file = _enter_source_file(source_file)
2225
+ body, expression = compile_cell(code, source_file or "<cell>")
2128
2226
  LOOP.run_until_complete(run_code(body, False))
2129
2227
  value = LOOP.run_until_complete(run_code(expression, True))
2130
2228
  text("stdout", stdout.getvalue())
@@ -2148,7 +2246,13 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
2148
2246
  "durationMs": elapsed(start),
2149
2247
  }
2150
2248
  finally:
2249
+ # Liveness and revocation first: nothing the cell installed (an import finder, a path hook) can skip them.
2151
2250
  KERNEL_TOOL_TOKEN.release()
2251
+ CURRENT_CELL_TOKEN.reset(cell_scope)
2252
+ try:
2253
+ leave_source_file()
2254
+ except BaseException as exc: # noqa: BROAD_EXCEPT_OK — user code can break the restore; it must not wedge the kernel.
2255
+ text("stderr", f"[senpi] %load could not fully restore the import path: {exc}\n")
2152
2256
  signal.signal(signal.SIGINT, signal.SIG_IGN)
2153
2257
  try:
2154
2258
  memory = KERNEL_MEMORY.after_cell()
@@ -2180,7 +2284,18 @@ def _handle_message(message: dict[str, Any]) -> bool:
2180
2284
  emit({"type": "ready"})
2181
2285
  return True
2182
2286
  if message_type == "run":
2183
- run_cell(str(message.get("cellId", "")), str(message.get("code", "")), message.get("preludes"))
2287
+ env_root = message.get("envRoot")
2288
+ if isinstance(env_root, str):
2289
+ _activate_env_root(env_root)
2290
+ source_file = message.get("sourceFile")
2291
+ token = message.get("bridgeCellToken")
2292
+ run_cell(
2293
+ str(message.get("cellId", "")),
2294
+ str(message.get("code", "")),
2295
+ message.get("preludes"),
2296
+ source_file if isinstance(source_file, str) and source_file else None,
2297
+ token if isinstance(token, str) else None,
2298
+ )
2184
2299
  return True
2185
2300
  if message_type == "close":
2186
2301
  KERNEL_TOOL_RUNNER.close()
@@ -2189,6 +2304,25 @@ def _handle_message(message: dict[str, Any]) -> bool:
2189
2304
  return True
2190
2305
 
2191
2306
 
2307
+ _ACTIVE_ENV_ROOT: list[str] = []
2308
+
2309
+
2310
+ def _activate_env_root(root: str) -> None:
2311
+ # One environment revision is on the import path at a time: a newer revision already holds every
2312
+ # package of the previous one, so it replaces that entry instead of stacking another.
2313
+ # An empty root means the session's current mode has nothing installed: the previous revision leaves too.
2314
+ wanted = [root] if root else []
2315
+ if _ACTIVE_ENV_ROOT == wanted:
2316
+ return
2317
+ for previous in _ACTIVE_ENV_ROOT:
2318
+ with contextlib.suppress(ValueError):
2319
+ sys.path.remove(previous)
2320
+ _ACTIVE_ENV_ROOT[:] = wanted
2321
+ if root:
2322
+ sys.path.insert(0, root)
2323
+ importlib.invalidate_caches()
2324
+
2325
+
2192
2326
  def _terminate_process_group() -> None:
2193
2327
  # The kernel is spawned into its own session (setsid), so its pid is its process
2194
2328
  # 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,141 @@
1
+ import type { ResolvedSandbox } from "../../config/feature-settings.ts";
2
+ import { marshalToolResult } from "../../tool/image.ts";
3
+ import type { EvalRuntimeInfo, 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
+ /**
17
+ * The runtime an isolated cell's result reports: the QuickJS build it runs on, never the persistent kernel's (senpi#2811).
18
+ * Read from the installed quickjs-wasi package the wasm loads from, only when an isolated cell runs; when that package
19
+ * cannot be resolved the cell itself fails with eval_isolate_unavailable, and the version says so.
20
+ */
21
+ export function sandboxRuntimeInfo(): EvalRuntimeInfo {
22
+ let version = "unavailable";
23
+ try {
24
+ // getBuiltinModule, not a static import: node:module would otherwise load with the extension.
25
+ const manifest: unknown = process.getBuiltinModule("node:module").createRequire(import.meta.url)(
26
+ "quickjs-wasi/package.json",
27
+ );
28
+ if (
29
+ typeof manifest === "object" &&
30
+ manifest !== null &&
31
+ "version" in manifest &&
32
+ typeof manifest.version === "string"
33
+ )
34
+ version = manifest.version;
35
+ } catch {
36
+ // Reported as "unavailable": the same missing package makes the cell fail with eval_isolate_unavailable.
37
+ }
38
+ return { name: "quickjs", version, isolation: "sandbox" };
39
+ }
40
+
41
+ // On the script's first line so reported line numbers still match the cell; globals, so a cell's own
42
+ // `const print` shadows them instead of failing as a redeclaration.
43
+ const OUTPUT_ALIASES =
44
+ '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; ';
45
+
46
+ const ERROR_CODES: Partial<Record<CodemodeError["kind"], string>> = {
47
+ timeout: "eval_isolate_timeout",
48
+ aborted: "eval_isolate_aborted",
49
+ sandbox: "eval_isolate_unavailable",
50
+ };
51
+
52
+ function codeFor(error: CodemodeError): string | undefined {
53
+ if (error.kind === "script" && error.reason === "memory") return "eval_isolate_memory_limit";
54
+ if (error.kind === "script" && error.name === "CodemodeStoreDisabledError") return "eval_isolate_no_state";
55
+ if (error.kind === "script" && error.reason === "unresolved") return "eval_isolate_unresolved_promise";
56
+ return ERROR_CODES[error.kind];
57
+ }
58
+
59
+ /** The persistent kernel never sees an isolated cell; its tools go through executeTool so permission hooks still apply. */
60
+ export function sandboxCellExecutor(code: string, options: SandboxCellOptions): HostCellExecutor {
61
+ return async ({ signal, emit }) => {
62
+ let sandbox: CodemodeSandbox;
63
+ try {
64
+ // Lazy: the vendored QuickJS runtime loads only when a session runs its first isolated cell. The wasm is loaded
65
+ // here, before any of the cell runs, so a missing runtime is reported as unavailable instead of failing inside
66
+ // the script.
67
+ const runtime = await import("./vendor/pi-codemode/runtime/host.ts");
68
+ const { loadQuickJSWasm } = await import("./vendor/pi-codemode/wasm.ts");
69
+ const wasm = await loadQuickJSWasm(options.wasmPath);
70
+ const items = new Map<number, string[]>();
71
+ const tools: CodemodeTool[] = options
72
+ .toolNames()
73
+ .filter((name) => name !== "eval")
74
+ .map((name) => ({
75
+ name,
76
+ description: options.describeTool?.(name) ?? "",
77
+ execute: async (args: unknown, context: { signal: AbortSignal }) =>
78
+ marshalToolResult(await options.executeTool(name, args, { signal: context.signal })),
79
+ }));
80
+ sandbox = new runtime.CodemodeSandbox({
81
+ wasm,
82
+ tools,
83
+ timeoutMs: options.sandbox.timeoutSeconds * 1_000,
84
+ memoryLimitBytes: options.sandbox.memoryMb * 1024 * 1024,
85
+ output: "stream",
86
+ builtins: { store: "reject" },
87
+ onOutputFrame: (frame: CodemodeOutputFrame) => {
88
+ const parts = items.get(frame.itemId) ?? [];
89
+ parts.push(frame.chunk);
90
+ if (!frame.final) {
91
+ items.set(frame.itemId, parts);
92
+ return;
93
+ }
94
+ items.delete(frame.itemId);
95
+ const payload = parts.join("");
96
+ if (frame.type === "image") {
97
+ emit({ type: "display", mimeType: frame.mimeType ?? "image/png", dataBase64: payload });
98
+ } else {
99
+ emit({ type: "text", stream: "stdout", data: payload.endsWith("\n") ? payload : `${payload}\n` });
100
+ }
101
+ },
102
+ });
103
+ } catch (error) {
104
+ // The full error, paths included, goes to the host's own log so a maintainer can diagnose it; the cell gets
105
+ // only what failed and the error's code. This covers every setup step: the runtime import, the tool list,
106
+ // the wasm load and the sandbox itself.
107
+ console.error("[senpi-codemode] isolated cell setup failed:", error);
108
+ const code =
109
+ error instanceof Error && "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
110
+ return {
111
+ ok: false,
112
+ error: {
113
+ name: "SandboxUnavailableError",
114
+ message: `eval_isolate_unavailable: the QuickJS runtime for isolated cells could not be loaded${code}; nothing in the cell ran`,
115
+ },
116
+ };
117
+ }
118
+ try {
119
+ const result = await sandbox.execute(OUTPUT_ALIASES + code, { signal });
120
+ if (result.ok) {
121
+ return result.value === undefined
122
+ ? { ok: true }
123
+ : {
124
+ ok: true,
125
+ valueRepr: typeof result.value === "string" ? result.value : JSON.stringify(result.value),
126
+ };
127
+ }
128
+ const errorCode = codeFor(result.error);
129
+ return {
130
+ ok: false,
131
+ error: {
132
+ ...(result.error.name === undefined ? {} : { name: result.error.name }),
133
+ message: errorCode === undefined ? result.error.message : `${errorCode}: ${result.error.message}`,
134
+ ...(result.error.stack === undefined ? {} : { stack: result.error.stack }),
135
+ },
136
+ };
137
+ } finally {
138
+ await sandbox.close();
139
+ }
140
+ };
141
+ }
@@ -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
+ }