@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +49 -1
  3. package/package.json +4 -4
  4. package/src/environments/js-environments.ts +208 -0
  5. package/src/environments/js-installer.ts +268 -0
  6. package/src/environments/js-revision-files.ts +91 -0
  7. package/src/environments/no-links.ts +132 -0
  8. package/src/environments/project-resolves.ts +54 -0
  9. package/src/environments/revision-store.ts +20 -4
  10. package/src/extension/runtime-factory.ts +9 -0
  11. package/src/index.ts +1 -0
  12. package/src/kernels/js/cell-dispatch.ts +44 -0
  13. package/src/kernels/js/context-manager.ts +23 -27
  14. package/src/kernels/js/host-entries.ts +62 -0
  15. package/src/kernels/js/local-module-loader.ts +17 -6
  16. package/src/kernels/js/worker-core.js +2 -0
  17. package/src/kernels/js/worker-package-resolve.js +153 -0
  18. package/src/kernels/py/host-cell.ts +8 -31
  19. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  20. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  21. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  22. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  23. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  24. package/src/kernels/shared/host-cell.ts +57 -0
  25. package/src/tool/cell-handler.ts +9 -11
  26. package/src/tool/eval-request.ts +23 -1
  27. package/src/tool/eval-tool-options.ts +3 -0
  28. package/src/tool/eval-tool.ts +16 -4
  29. package/src/tool/load-cell.ts +18 -3
  30. package/src/tool/magic-cell-host.ts +60 -2
  31. package/src/tool/magic-cells.ts +12 -2
  32. package/src/tool/run-eval-cell.ts +30 -3
  33. package/src/tool/types.ts +21 -1
@@ -0,0 +1,153 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { isBuiltin } from "node:module";
3
+ import { dirname, join, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ // A cell's bare import runs `import()` from this worker's own module, so the runtime resolves it from Senpi's install
7
+ // tree, not the session's project or its managed package revision. This resolver walks `node_modules` from each root
8
+ // the cell names, in order, and resolves the package entry the way ESM does (the `exports` field with import
9
+ // conditions), so ESM-only packages load and the first root that has the package wins.
10
+ const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
11
+ const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
12
+
13
+ function isFile(path) {
14
+ try {
15
+ return statSync(path).isFile();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function isDirectory(path) {
22
+ try {
23
+ return statSync(path).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function splitSpecifier(specifier) {
30
+ const parts = specifier.split("/");
31
+ const nameParts = specifier.startsWith("@") ? 2 : 1;
32
+ if (parts.length < nameParts || parts.slice(0, nameParts).some((part) => part === "" || part === "." || part === ".."))
33
+ return undefined;
34
+ const rest = parts.slice(nameParts).join("/");
35
+ return { name: parts.slice(0, nameParts).join("/"), subpath: rest === "" ? "." : `./${rest}` };
36
+ }
37
+
38
+ function conditionalTarget(target) {
39
+ if (typeof target === "string") return target;
40
+ if (Array.isArray(target)) {
41
+ for (const candidate of target) {
42
+ const resolved = conditionalTarget(candidate);
43
+ if (resolved !== undefined) return resolved;
44
+ }
45
+ return undefined;
46
+ }
47
+ if (target === null || typeof target !== "object") return undefined;
48
+ for (const [condition, value] of Object.entries(target)) {
49
+ if (condition !== "default" && !CONDITIONS.includes(condition)) continue;
50
+ const resolved = conditionalTarget(value);
51
+ if (resolved !== undefined) return resolved;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function exportsTarget(exportsField, subpath) {
57
+ const subpathKeyed =
58
+ exportsField !== null &&
59
+ typeof exportsField === "object" &&
60
+ !Array.isArray(exportsField) &&
61
+ Object.keys(exportsField).some((key) => key.startsWith("."));
62
+ if (!subpathKeyed) return subpath === "." ? conditionalTarget(exportsField) : undefined;
63
+ if (Object.hasOwn(exportsField, subpath)) return conditionalTarget(exportsField[subpath]);
64
+ let best;
65
+ for (const key of Object.keys(exportsField)) {
66
+ const star = key.indexOf("*");
67
+ if (star < 0 || key.includes("*", star + 1)) continue;
68
+ const prefix = key.slice(0, star);
69
+ const suffix = key.slice(star + 1);
70
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
71
+ continue;
72
+ if (best === undefined || prefix.length > best.prefix.length) {
73
+ best = { key, prefix, match: subpath.slice(prefix.length, subpath.length - suffix.length) };
74
+ }
75
+ }
76
+ if (best === undefined || hasInvalidSegment(best.match)) return undefined;
77
+ const target = conditionalTarget(exportsField[best.key]);
78
+ return target === undefined ? undefined : target.replaceAll("*", best.match);
79
+ }
80
+
81
+ // Node refuses a pattern match or target with "." / ".." / "node_modules" segments (ERR_INVALID_MODULE_SPECIFIER).
82
+ function hasInvalidSegment(path) {
83
+ return path.split(/[\\/]/).some((segment) => segment === "." || segment === ".." || segment === "node_modules");
84
+ }
85
+
86
+ function legacyFile(path) {
87
+ for (const candidate of [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")]) {
88
+ if (isFile(candidate)) return candidate;
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ function packageEntry(packageDir, subpath, specifier) {
94
+ let manifest;
95
+ try {
96
+ manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
97
+ } catch {
98
+ return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
99
+ }
100
+ if (manifest.exports !== undefined && manifest.exports !== null) {
101
+ const target = exportsTarget(manifest.exports, subpath);
102
+ if (typeof target !== "string" || !target.startsWith("./") || hasInvalidSegment(target.slice(2))) {
103
+ const error = new Error(`Package subpath '${subpath}' is not exported for import by '${specifier}'`);
104
+ error.code = "ERR_PACKAGE_PATH_NOT_EXPORTED";
105
+ throw error;
106
+ }
107
+ return join(packageDir, target);
108
+ }
109
+ if (subpath !== ".") return legacyFile(join(packageDir, subpath));
110
+ const moduleEntry = typeof globalThis.Bun === "object" && typeof manifest.module === "string" ? manifest.module : undefined;
111
+ const main = moduleEntry ?? (typeof manifest.main === "string" ? manifest.main : "index.js");
112
+ return legacyFile(join(packageDir, main));
113
+ }
114
+
115
+ function findPackageDir(name, rootUrl) {
116
+ let directory = fileURLToPath(rootUrl);
117
+ for (;;) {
118
+ const candidate = join(directory, "node_modules", name);
119
+ if (isDirectory(candidate)) return candidate;
120
+ const parent = dirname(directory);
121
+ if (parent === directory) return undefined;
122
+ directory = parent;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The file URL the bare `specifier` resolves to from the first of `rootUrls` whose `node_modules` chain has its
128
+ * package, or undefined when it names a builtin or no root has the package (the caller then imports it natively).
129
+ */
130
+ export function resolvePackage(specifier, rootUrls) {
131
+ if (specifier.includes(":") || isBuiltin(specifier)) return undefined;
132
+ const parts = splitSpecifier(specifier);
133
+ if (parts === undefined) return undefined;
134
+ for (const rootUrl of rootUrls) {
135
+ if (typeof rootUrl !== "string") continue;
136
+ const packageDir = findPackageDir(parts.name, rootUrl);
137
+ if (packageDir === undefined) continue;
138
+ const entry = packageEntry(packageDir, parts.subpath, specifier);
139
+ if (entry === undefined) continue;
140
+ let real = entry;
141
+ try {
142
+ real = realpathSync(entry);
143
+ } catch {
144
+ // A missing target fails in the import itself, with the runtime's own error.
145
+ }
146
+ return pathToFileURL(real.endsWith(sep) ? real.slice(0, -1) : real).href;
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ export function installPackageResolver() {
152
+ globalThis[RESOLVE_PACKAGE] = resolvePackage;
153
+ }
@@ -1,42 +1,19 @@
1
1
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import type { HostCellExecutor } from "../../tool/types.ts";
3
-
4
- type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
5
-
3
+ import { runHostCell as runSharedHostCell } from "../shared/host-cell.ts";
6
4
  import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
7
- import { failedPythonResult } from "./transport.ts";
8
5
 
9
6
  export function runHostCell(
10
7
  pending: PendingRun,
11
8
  host: HostCellExecutor,
12
9
  io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
13
10
  ): void {
14
- const abort = new AbortController();
15
- pending.hostAbort = abort;
16
- const cellId = pending.input.cellId;
17
- let finished = false;
18
- // Output from an executor that was aborted, or after its outcome, belongs to no cell.
19
- const emit = (message: KernelToHostMessage): void => {
20
- if (!finished && !abort.signal.aborted) io.emit(message);
21
- };
22
- // Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
23
- const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
24
- (outcome): ResultMessage =>
25
- outcome.ok
26
- ? {
27
- type: "result",
28
- cellId,
29
- ok: true,
30
- durationMs: 0,
31
- ...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
32
- }
33
- : { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
34
- (error: unknown): ResultMessage =>
35
- failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
36
- );
37
- pending.hostDone = done;
38
- void done.then((result) => {
39
- finished = true;
40
- if (!abort.signal.aborted) io.settle(result);
11
+ const run = runSharedHostCell(pending.input.cellId, host, {
12
+ emit: io.emit,
13
+ settle: io.settle,
14
+ durationMs: () => 0,
15
+ settleAfterAbort: false,
41
16
  });
17
+ pending.hostAbort = run.abort;
18
+ pending.hostDone = run.done;
42
19
  }
@@ -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,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
+ }
@@ -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);
@@ -17,9 +17,19 @@ export function normalizeEvalSummary(value: unknown): string | undefined {
17
17
  return normalized.length === 0 ? undefined : normalized;
18
18
  }
19
19
 
20
+ export class EvalIsolateInvalidError extends TypeError {
21
+ readonly name = "EvalIsolateInvalidError";
22
+ readonly code = "eval_isolate_invalid";
23
+
24
+ constructor(reason: string) {
25
+ super(`eval_isolate_invalid: ${reason}`);
26
+ }
27
+ }
28
+
20
29
  export function parseEvalRequest(
21
30
  params: unknown,
22
31
  enabledLanguages: readonly EvalLanguage[] = evalLanguageOrder,
32
+ options: { readonly sandbox?: boolean } = {},
23
33
  ): EvalToolRequest {
24
34
  if (!isRecord(params)) throw new TypeError("eval parameters must be an object");
25
35
  if (params.action === "list") return { action: "list" };
@@ -30,7 +40,9 @@ export function parseEvalRequest(
30
40
  }
31
41
  if (params.action !== undefined && params.action !== "run")
32
42
  throw new TypeError(`Unknown eval action "${String(params.action)}"`);
33
- if (!isEvalLanguage(params.language)) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
43
+ if (params.language === undefined) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
44
+ if (!isEvalLanguage(params.language))
45
+ throw new TypeError(`eval run language must be one of: ${enabledLanguages.join(", ")}`);
34
46
  if (typeof params.code !== "string")
35
47
  throw new TypeError("eval run requires code — the cell body to execute, verbatim");
36
48
  const summary = normalizeEvalSummary(params.summary);
@@ -40,6 +52,15 @@ export function parseEvalRequest(
40
52
  );
41
53
  if (params.on_timeout !== undefined && params.on_timeout !== "detach" && params.on_timeout !== "error")
42
54
  throw new TypeError(`Unknown eval on_timeout value "${String(params.on_timeout)}"`);
55
+ if (params.isolate !== undefined && params.isolate !== false) {
56
+ if (params.isolate !== true) throw new EvalIsolateInvalidError("isolate must be true or false");
57
+ if (options.sandbox !== true) {
58
+ throw new EvalIsolateInvalidError("sandbox cells are turned off (set sandbox.enabled in codemode settings)");
59
+ }
60
+ if (params.language !== "js")
61
+ throw new EvalIsolateInvalidError('sandbox cells run JavaScript only (language: "js")');
62
+ if (params.reset === true) throw new EvalIsolateInvalidError("a sandbox cell starts fresh; reset does not apply");
63
+ }
43
64
  return {
44
65
  language: params.language,
45
66
  code: params.code,
@@ -48,6 +69,7 @@ export function parseEvalRequest(
48
69
  ...(typeof params.timeout === "number" ? { timeout: params.timeout } : {}),
49
70
  ...(params.on_timeout === "detach" || params.on_timeout === "error" ? { on_timeout: params.on_timeout } : {}),
50
71
  ...(typeof params.reset === "boolean" ? { reset: params.reset } : {}),
72
+ ...(params.isolate === true ? { isolate: true } : {}),
51
73
  };
52
74
  }
53
75
 
@@ -8,6 +8,7 @@ import type {
8
8
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
9
9
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
10
10
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
11
+ import type { JsEnvironments } from "../environments/js-environments.ts";
11
12
  import type { PythonEnvironments } from "../environments/python-environments.ts";
12
13
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
13
14
  import type { HandleRegistry } from "../handles/handle-registry.ts";
@@ -72,6 +73,8 @@ export interface CreateEvalToolOptions {
72
73
  readonly kernelPreludes?: () => readonly KernelPreludeContribution[];
73
74
  /** The session's Python environments: `%pip` / `%environment` cells and the import root of every Python cell. */
74
75
  readonly pythonEnvironments?: PythonEnvironments;
76
+ /** The session's managed JavaScript packages: `%bun add` / `%npm add` cells and the bare-import fallback root. */
77
+ readonly jsEnvironments?: JsEnvironments;
75
78
  /** Contributions whose documentation lines the description lists; snapshot taken when the tool is (re)registered. */
76
79
  readonly promptKernelPreludes?: readonly KernelPreludeContribution[];
77
80
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentToolResult, ToolDefinition } from "@code-yeongyu/senpi";
2
2
  import type { TUnsafe } from "typebox";
3
+ import { resolveSandbox } from "../config/feature-settings.ts";
3
4
  import {
4
5
  DEFAULT_FOREGROUND_WINDOW_SECONDS,
5
6
  defaultCodemodeSettings,
@@ -8,7 +9,12 @@ import {
8
9
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
9
10
  import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
10
11
  import { executeEvalControl } from "./detached-eval-result.ts";
11
- import { isEvalControlRequest, normalizeEvalSummary, parseEvalRequest } from "./eval-request.ts";
12
+ import {
13
+ EvalIsolateInvalidError,
14
+ isEvalControlRequest,
15
+ normalizeEvalSummary,
16
+ parseEvalRequest,
17
+ } from "./eval-request.ts";
12
18
  import type { CreateEvalToolOptions } from "./eval-tool-options.ts";
13
19
  import { runEvalCell } from "./run-eval-cell.ts";
14
20
  import {
@@ -43,7 +49,8 @@ export function createEvalTool(options: CreateEvalToolOptions) {
43
49
  foregroundWindowSeconds,
44
50
  hardLimitSeconds: options.hardLimitSeconds ?? defaultEvalDeadlineSeconds.hardLimitSeconds,
45
51
  };
46
- const parameters = createEvalInputSchema(options.enabledLanguages, deadlines);
52
+ const sandbox = resolveSandbox(options.settings ?? defaultCodemodeSettings).enabled;
53
+ const parameters = createEvalInputSchema(options.enabledLanguages, deadlines, { sandbox });
47
54
  const prompt = buildEvalPrompt(options.enabledLanguages, {
48
55
  spawns: options.spawns ?? false,
49
56
  monitor: options.monitor,
@@ -74,9 +81,14 @@ export function createEvalTool(options: CreateEvalToolOptions) {
74
81
  async function execute(
75
82
  ...[toolCallId, params, signal, onUpdate, ctx]: EvalExecuteArgs<EvalToolRequest>
76
83
  ): Promise<AgentToolResult<EvalResultDetails>> {
77
- const request = parseEvalRequest(params, languages);
84
+ const request = parseEvalRequest(params, languages, { sandbox });
78
85
  if (isEvalControlRequest(request)) return await executeEvalControl(cellManager, request);
79
- if (options.proxyExecutor) return await options.proxyExecutor(request, signal);
86
+ if (options.proxyExecutor) {
87
+ // A proxy runs cells elsewhere and knows nothing of sandbox cells: an isolated cell must never run unisolated.
88
+ if ("isolate" in request && request.isolate === true)
89
+ throw new EvalIsolateInvalidError("isolate: true is not available through this eval proxy");
90
+ return await options.proxyExecutor(request, signal);
91
+ }
80
92
  if (!languages.includes(request.language))
81
93
  throw new RangeError(
82
94
  `Unsupported eval language "${request.language}". Enabled languages: ${languages.join(", ")}`,
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
@@ -50,6 +50,18 @@ function resolveTarget(target: string, options: LoadCellOptions): { path: string
50
50
  * Reads a `%load` target when its cell's turn comes in the kernel's queue, so a file the previous cell wrote is
51
51
  * there. Every failure is a refusal that names the target as the user wrote it, never an absolute path.
52
52
  */
53
+ /** Reads from `fd` until EOF or `limit` bytes, whichever comes first; a file still growing cannot exceed it. */
54
+ export function readAtMost(fd: number, limit: number): Buffer {
55
+ const buffer = Buffer.alloc(limit);
56
+ let filled = 0;
57
+ while (filled < limit) {
58
+ const read = readSync(fd, buffer, filled, limit - filled, null);
59
+ if (read === 0) break;
60
+ filled += read;
61
+ }
62
+ return buffer.subarray(0, filled);
63
+ }
64
+
53
65
  export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
54
66
  const resolved = resolveTarget(target, options);
55
67
  if ("message" in resolved) return { ok: false, message: resolved.message };
@@ -60,8 +72,11 @@ export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
60
72
  fd = openSync(resolved.path, constants.O_RDONLY | constants.O_NONBLOCK);
61
73
  const info = fstatSync(fd);
62
74
  if (!info.isFile()) return { ok: false, message: `not a file: ${target}` };
63
- if (info.size > MAX_LOAD_BYTES) return { ok: false, message: `%load reads files up to 8 MiB: ${target}` };
64
- return { ok: true, code: readFileSync(fd, "utf8"), sourceFile: resolved.path };
75
+ const tooLarge = { ok: false as const, message: `%load reads files up to 8 MiB: ${target}` };
76
+ if (info.size > MAX_LOAD_BYTES) return tooLarge;
77
+ const bytes = readAtMost(fd, MAX_LOAD_BYTES + 1);
78
+ if (bytes.length > MAX_LOAD_BYTES) return tooLarge;
79
+ return { ok: true, code: bytes.toString("utf8"), sourceFile: resolved.path };
65
80
  } catch (error) {
66
81
  const code = errorCode(error);
67
82
  if (code === "ENOENT" || code === "ENOTDIR") return { ok: false, message: `file not found: ${target}` };