@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.
- package/CHANGELOG.md +178 -0
- package/README.md +212 -14
- package/package.json +6 -5
- package/src/bridge/http-server.ts +9 -1
- package/src/bridge/protocol.ts +12 -3
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/eval-environment-schemas.ts +48 -0
- package/src/bridges/eval-virtual-schemas.ts +12 -4
- package/src/bridges/reserved-dispatch.ts +13 -1
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/packages-install.ts +98 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/py-environment.ts +7 -3
- package/src/environments/py-installer.ts +68 -4
- package/src/environments/python-environments.ts +85 -0
- package/src/environments/revision-store.ts +38 -7
- package/src/extension/bridge-tool-call.ts +7 -1
- package/src/extension/cell-kernel-tools.ts +28 -0
- package/src/extension/eval-notifier.ts +13 -2
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/kernel-replacement.ts +20 -0
- package/src/extension/runtime-factory.ts +53 -3
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager-proxy.ts +6 -0
- package/src/extension/session-manager.ts +26 -4
- package/src/index.ts +4 -2
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/jl/prelude.jl +6 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/cell-ownership.js +268 -0
- package/src/kernels/js/cell-run-context.js +89 -0
- package/src/kernels/js/context-manager.ts +48 -39
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/kernel-contract.ts +16 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +21 -5
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +466 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/rejection-reports.js +61 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/worker-core.js +53 -8
- package/src/kernels/js/worker-cwd.js +5 -2
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-indirect-eval.js +37 -13
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-require.d.ts +19 -0
- package/src/kernels/js/worker-require.js +52 -0
- package/src/kernels/js/worker-runtime.js +45 -9
- package/src/kernels/js/worker-shadow-guard.js +2 -6
- package/src/kernels/js/worker-shell-capture.js +5 -0
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +49 -1
- package/src/kernels/js/worker-webview.js +15 -1
- package/src/kernels/js/workpool.js +5 -2
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/kernel.ts +8 -2
- package/src/kernels/py/prelude.py +145 -11
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/py/transport.ts +13 -1
- package/src/kernels/rb/workpool.rb +7 -1
- package/src/kernels/sandbox/sandbox-cell.ts +141 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/cell-source-at-start.ts +18 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-handler.ts +13 -12
- package/src/tool/cell-reserved-dispatch.ts +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-contract.ts +3 -0
- package/src/tool/detached-cell-manager.ts +4 -8
- package/src/tool/detached-cell-notification.ts +11 -63
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/detached-notification-queue.ts +1 -2
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +6 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/live-headline.ts +60 -0
- package/src/tool/load-cell.ts +88 -0
- package/src/tool/magic-cell-host.ts +123 -0
- package/src/tool/magic-cells.ts +75 -0
- package/src/tool/managed-cell.ts +0 -4
- package/src/tool/render.ts +74 -15
- package/src/tool/run-eval-cell.ts +73 -1
- package/src/tool/runtime-label.ts +2 -1
- 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
|
-
|
|
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(
|
|
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,
|
|
2142
|
+
return compile(body, filename, "exec", flags=TLA_FLAG), compile(
|
|
2082
2143
|
expression,
|
|
2083
|
-
|
|
2144
|
+
filename,
|
|
2084
2145
|
"eval",
|
|
2085
2146
|
flags=TLA_FLAG,
|
|
2086
2147
|
)
|
|
2087
|
-
return compile(module,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|