@code-yeongyu/senpi-codemode 2026.10.10-4 → 2026.10.10-6

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 CHANGED
@@ -6,10 +6,54 @@
6
6
 
7
7
  ### Added
8
8
 
9
+ - `tool_schema("eval:environments")` and `tool_schema("eval:isolation")` document the package magics (`%pip`, `%bun`/`%npm`, `%environment`, `%load`) and isolated cells on demand, with the error codes each one raises. Nothing is added to the eval prompt or its input schema.
10
+
11
+ ### Changed
12
+
13
+ ### Fixed
14
+
15
+ ### Removed
16
+
17
+ ## [2026.10.10-6] - 2026-10-07
18
+
19
+ ### Breaking Changes
20
+
21
+ ### Added
22
+
23
+ ### Changed
24
+
25
+ ### Fixed
26
+
27
+ ### Removed
28
+
29
+ ## [2026.10.10-5] - 2026-10-07
30
+
31
+ ### Breaking Changes
32
+
33
+ ### Added
34
+
9
35
  ### Changed
10
36
 
37
+ - A running, queued or detached eval row now leads with the cell's summary (or its first code line), then the language, state and elapsed time, on one line; the code is shown on expand. A call still streaming its arguments shows that row instead of the raw `eval code="..."` fallback. Completed rows are unchanged ([#2802](https://github.com/code-yeongyu/senpi/issues/2802)).
38
+
39
+ - A JavaScript cell may declare a name the kernel or platform already defines (`log`, `fetch`, `print`, `URL`, ...): the value persists for your later cells while the kernel and imported libraries keep the original, `delete <name>` restores it, and the cell notes the shadowing ([#2793](https://github.com/code-yeongyu/senpi/issues/2793)).
40
+
11
41
  ### Fixed
12
42
 
43
+ - A live eval row whose cell has no summary skips a first code line that holds only escape or control characters and leads with the next line that has content, instead of showing only an ellipsis ([#2850](https://github.com/code-yeongyu/senpi/issues/2850)).
44
+
45
+ - The `require` in a JavaScript cell carries `require.resolve`, `require.resolve.paths` and `require.cache` like Node's own `require`, and the call and `resolve` share one lookup, so they always name the same copy: builtins natively (including Bun's, such as `bun:sqlite`), then the project, then the managed package environment ([#2832](https://github.com/code-yeongyu/senpi/issues/2832)).
46
+
47
+ - A live eval row whose cell has no summary sanitizes its first code line before measuring it, so escape and control characters in that line never reach the one-line row ([#2839](https://github.com/code-yeongyu/senpi/issues/2839)).
48
+
49
+ - A live eval row stays one line in every terminal: its headline is measured and cut in screen cells, so a summary with wide characters (Korean, Chinese, Japanese, emoji) no longer wraps a narrow terminal, and a `peek`/`stop` call still streaming in renders `eval peek` instead of `eval peek undefined` ([#2831](https://github.com/code-yeongyu/senpi/issues/2831)).
50
+
51
+ - Stopping or timing out a JavaScript cell that awaits something that never settles (a promise, a `fetch` whose server never answers, a polling loop on timers, `Bun.sleep` or `node:timers/promises`, a loop of short `Bun.spawn` children) now keeps the worker and every global instead of restarting it. Stop ends the cell and everything it started: its timers are cleared, pending sleeps and `fetch` requests reject, and the sockets, servers, WebSockets, WebViews, nested workers and child processes it opened are closed; the result arrives once its children are gone. A stopped cell's own `catch`/`finally` can no longer print, call tools, start processes, schedule timers or open connections. An unhandled promise rejection no longer crashes the JavaScript kernel: it is reported on the running or next cell, naming the cell it came from, with bursts folded into one line; an uncaught exception still restarts the worker. A cell stopped during a `Bun.$` command still restarts the worker, as before ([#2788](https://github.com/code-yeongyu/senpi/issues/2788)).
52
+
53
+ - JavaScript cells can call `require(...)` and `createRequire(...)`: builtins, relative CommonJS and JSON files, and packages from the project or the managed package environment resolve as they do for `import` ([#2792](https://github.com/code-yeongyu/senpi/issues/2792)).
54
+
55
+ - A detached eval cell's completion notification now carries the same output its result would have shown in the foreground (head, tail, elision marker and full-output notice) instead of a 512-byte tail, and images the cell displayed are delivered with the notification ([#2789](https://github.com/code-yeongyu/senpi/issues/2789)).
56
+
13
57
  ### Removed
14
58
 
15
59
  ## [2026.10.10-4] - 2026-10-06
package/README.md CHANGED
@@ -363,7 +363,8 @@ options object and asynchronous helpers are `await`-able.
363
363
  | `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
364
364
  | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. Includes the session's `PI_*` values (see [Session environment](#session-environment)). |
365
365
  | `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline and returns `{ text, images?, details?, hasError? }` in every kernel; image blocks arrive as `images[i] = { mimeType, dataBase64 }`. |
366
- | `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
366
+ | `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. Five virtual entries document codemode itself on demand and are never listed: `eval:wait`, `eval:helpers`, `eval:kernel-tools`, `eval:environments` (the `%pip`, `%bun`/`%npm`, `%environment` and `%load` magics, their results and error codes) and `eval:isolation` (`isolate: true` cells, their limits and error codes). |
367
+ | `require(specifier)`, `createRequire(path)` (js) | CommonJS loading inside a cell, resolved like `import`: builtins (with or without `node:`), relative files and JSON from the cell's directory, bare packages from the session's project and then its managed package environment (`%bun add`/`%npm add`). A missing module raises the runtime's own `MODULE_NOT_FOUND` error. Like Node's `require`, it carries `require.resolve` (the same lookup the call uses, so both name the same copy; builtins resolve to the runtime's own id), `require.resolve.paths` and `require.cache` (the runtime's CommonJS cache). |
367
368
  | `tool(fn, metadata?)` (js) | Registers a named function as a kernel tool for in-process children. `metadata.name` registers it under that name instead of the function's; arguments are still passed in the function's parameter order. |
368
369
  | `tool.defined()` / `tool.undefine(name)` (js) | List the kernel tools this kernel defines (sorted), and remove one (`true` if it existed). A descriptor taken before `undefine` can no longer be invoked. Both names are reserved: a kernel tool can't be registered as `defined` or `undefine` (`reserved_tool_name`), and a host tool with either name is shadowed in the `tool` namespace, so it can't be called from a JavaScript cell. |
369
370
  | `@tool` / `@tool(name=, description=, schema=)` (py) | Registers a Python function as a kernel tool for in-process children; the schema is inferred from its type hints (`tool_schema("eval:kernel-tools")` lists the rules). Callbacks run while the kernel is idle or its cell is parked in a host call, never in the middle of a running computation. `tool.defined()` and `tool.undefine(name)` work as in JavaScript. Ruby and Julia kernels answer `tools_unavailable`. |
@@ -465,6 +466,10 @@ free; otherwise it settles `cancelled` with `eval_background_capacity_reached`,
465
466
  listing the live cells and a stop-or-wait remedy. Cells that complete inside the
466
467
  window return normally. Cancelling a queued cell never interrupts its predecessor.
467
468
  Do not re-run a detached or queued cell; each detached cell completes as one notification.
469
+ The notification carries the same text the cell's result would have shown in the
470
+ foreground (the output sink's configured head, its tail, the middle-elision marker and
471
+ the full-output artifact notice), framed by the outcome line and the kernel-state note,
472
+ plus any images the cell displayed.
468
473
 
469
474
  Queued steering also detaches an eligible interactive foreground call, including
470
475
  one paused in a host tool bridge, without cancelling its computation. If the
@@ -505,11 +510,29 @@ Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
505
510
  `eval({ action: "stop", cell_id })` to cancel it. Stopping a queued cell removes it
506
511
  without interrupting the active cell; kernel state is retained. Python running-cell stop interrupts the
507
512
  existing kernel and preserves variables. JavaScript stop is cooperative first:
508
- the worker rejects the cell's pending bridge `tool.*` calls and kills the
509
- `Bun.spawn` children it started, and a cell that settles within the 2 s grace
510
- keeps the worker and every global. Only a cell that stays unsettled (a
511
- never-resolving promise, an un-abortable `fetch`, a `Bun.$` command) costs the
512
- worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
513
+ Stop ends the cell and everything it started, not the kernel's variables. The
514
+ worker rejects the cell's pending bridge `tool.*` calls and releases the cell:
515
+ its timers are cleared (the globals and the `node:timers` module alike), its
516
+ pending `Bun.sleep`, `node:timers/promises` waits, `fetch` requests and file
517
+ reads reject with the interruption, and the sockets, servers, file streams,
518
+ `readline` interfaces, WebSockets, WebViews, nested workers and child processes
519
+ it opened are closed or terminated, whether made with a factory or a
520
+ constructor. A resource that will not close is reported on the cell output. The stop result arrives once those children are gone, and the
521
+ worker and every global from earlier cells survive. If the stopped cell's own
522
+ `catch`/`finally` still runs, it cannot print, call tools, start processes,
523
+ schedule timers or open connections. One boundary remains: code that resumes
524
+ because a later cell resolves a promise the stopped cell was awaiting runs
525
+ until it reaches an operation a stopped cell may no longer start (output, tool
526
+ calls, processes, timers, network, files, message channels, workers).
527
+
528
+ An unhandled promise rejection never crashes the JavaScript kernel; its
529
+ variables are kept, as in the Node REPL and Jupyter. The rejection is reported
530
+ on the cell that is running, or on the next cell, naming the cell that started
531
+ the work when it is known ("from cell <id>, after it was stopped"), with the
532
+ error message and the top of its stack. A burst becomes one report plus
533
+ "... and K more unhandled promise rejections". A fatal error (an uncaught
534
+ exception) still restarts the worker, and the result says variables are lost. Only a cell stopped during a `Bun.$` command costs
535
+ the worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
513
536
  `child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
514
537
  deadline a fresh worker replaces it, the cell output gains a stderr line naming
515
538
  the blocked synchronous call, and the blocked call keeps running until it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.10-4",
3
+ "version": "2026.10.10-6",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-4",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-6",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.10-4"
41
+ "@code-yeongyu/senpi": "2026.10.10-6"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.10-4"
44
+ "@code-yeongyu/senpi": "2026.10.10-6"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -0,0 +1,48 @@
1
+ import type { EvalSchemaResult } from "./schema-bridge.ts";
2
+
3
+ export type NamedEvalSchema = Extract<EvalSchemaResult, { readonly name: string }>;
4
+
5
+ /**
6
+ * The on-demand documentation for package environments and isolated cells (`tool_schema("eval:environments")` and
7
+ * `tool_schema("eval:isolation")`). Like the other virtual entries they are never listed, never run anything, and add
8
+ * nothing to the eval prompt or input schema. Every magic, code and message named here is the one the source emits.
9
+ */
10
+ export const environmentsEntry: NamedEvalSchema = {
11
+ name: "eval:environments",
12
+ description: [
13
+ "Host magic cells: a cell whose first code line (blank and comment lines skipped) is one of these runs on the host, in the language kernel's queue, so it orders with ordinary cells and a queued one is removed by stop like any queued cell. A magic followed by more code is refused: put it on its own cell, then run the code that uses it in the next cell.",
14
+ "py: %pip install <requirements ...> installs into the session's environment (a trailing backslash continues the line). Result: installed <packages> into <mode> (revision <n>); already-imported modules stay cached until reset.",
15
+ "js: %bun add <packages ...> or %npm add <packages ...> (install is accepted as a synonym of add). Result: added <packages> with <installer> into <mode>[ (revision <n>)]; already-imported modules stay cached until reset. A package the project's own node_modules still shadows is named with environment_resolution_conflict in the result.",
16
+ "py and js: %environment managed | project selects where installs go. managed (the default) uses per-session revisions under the session's artifacts directory (or environments.managedRoot) and never touches project files; project installs into the session directory (py: .senpi/python-packages; js: the project root).",
17
+ "py and js: %load <file> runs a local file as a cell, read when its turn comes in the queue, with the file's name in tracebacks; definitions persist like any cell. It accepts a path relative to the session directory, local:// or file://, reads files up to 8 MiB and never fetches remote URLs.",
18
+ "Installs pause the run budget like a bridge call, stream the installer's output into the cell, detach past the foreground window like any cell, and are stopped by stop.",
19
+ "Install failures are cell errors that start with their code: environment_install_failed (a bad argument or an installer failure, with the installer's stderr tail), environment_install_cancelled (stopped before or during the install), environment_installer_unavailable (no interpreter or no pip; no JavaScript package environment in this session; neither bun nor npm on PATH, or the chosen one missing; the installer failed to start; or installs turned off with environments.autoProvision: false), environment_resolution_conflict (py: pip reported a dependency conflict). A %load failure, and a magic refused because more code follows it, are plain cell errors without a code.",
20
+ ].join("\n"),
21
+ parameters: {
22
+ type: "object",
23
+ properties: {
24
+ "%pip": { description: "py: %pip install <requirements ...>" },
25
+ "%bun": { description: "js: %bun add <packages ...>" },
26
+ "%npm": { description: "js: %npm add <packages ...>" },
27
+ "%environment": { enum: ["managed", "project"] },
28
+ "%load": { description: "py and js: %load <path | local://... | file://...>" },
29
+ },
30
+ },
31
+ };
32
+
33
+ export const isolationEntry: NamedEvalSchema = {
34
+ name: "eval:isolation",
35
+ description: [
36
+ "isolate: true runs one JavaScript cell in a fresh QuickJS sandbox instead of the persistent kernel. It is available only when sandbox.enabled is on in the codemode settings; only then does the eval input schema carry the isolate field. An older senpi drops the field and would run the code as an ordinary cell, so check this entry before relying on it.",
37
+ 'Requests are refused with eval_isolate_invalid when sandbox cells are turned off, when language is not "js", when isolate is not a boolean, with reset: true (a sandbox cell always starts fresh), or when eval runs through a proxy that cannot isolate a cell.',
38
+ "An isolated cell shares nothing with the kernel. Its globals are print, display, text, image, console, exit, tool.<name>(args) (also reachable as tools), ALL_TOOLS (the names and descriptions of the tools it may call), load and store. Its tool calls go through the host, so permission hooks still apply, and tool calls still pending when the script settles are aborted. The store is rejected: load returns nothing and store fails with eval_isolate_no_state.",
39
+ "Limits: sandbox.timeoutSeconds (default 300) and sandbox.memoryMb (default 64; SENPI_CODEMODE_SANDBOX_MEMORY_MB overrides it). The result's runtime names quickjs and its version, with isolation sandbox.",
40
+ "Failures start with their code: eval_isolate_timeout, eval_isolate_aborted, eval_isolate_memory_limit, eval_isolate_unresolved_promise (the script ended with a promise still pending), eval_isolate_no_state, eval_isolate_unavailable (the sandbox itself failed: it could not load, or its worker crashed, exited or lost its output; if that happened after the script started, tool calls the cell made may already have run, so check their effects before retrying).",
41
+ ].join("\n"),
42
+ parameters: {
43
+ type: "object",
44
+ properties: {
45
+ isolate: { type: "boolean", description: 'true with language: "js" runs the cell in a fresh sandbox' },
46
+ },
47
+ },
48
+ };
@@ -1,3 +1,4 @@
1
+ import { environmentsEntry, isolationEntry, type NamedEvalSchema } from "./eval-environment-schemas.ts";
1
2
  import type { EvalSchemaResult } from "./schema-bridge.ts";
2
3
 
3
4
  /**
@@ -22,7 +23,7 @@ const handleRefSchema = {
22
23
  required: ["kind", "id", "run_epoch"],
23
24
  } as const;
24
25
 
25
- const waitEntry: EvalSchemaResult = {
26
+ const waitEntry: NamedEvalSchema = {
26
27
  name: "eval:wait",
27
28
  description: [
28
29
  "wait(handles, {timeout?, mode?}) blocks the cell until the given handles settle (js: await wait(...); py/rb: wait(...); jl: wait(handle(node)) or wait([handle(a), handle(b)])).",
@@ -44,7 +45,7 @@ const waitEntry: EvalSchemaResult = {
44
45
  },
45
46
  };
46
47
 
47
- const helpersEntry: EvalSchemaResult = {
48
+ const helpersEntry: NamedEvalSchema = {
48
49
  name: "eval:helpers",
49
50
  description: [
50
51
  "Advanced cell helpers (all four languages; js helpers are awaited).",
@@ -70,7 +71,7 @@ const helpersEntry: EvalSchemaResult = {
70
71
  },
71
72
  };
72
73
 
73
- const kernelToolsEntry: EvalSchemaResult = {
74
+ const kernelToolsEntry: NamedEvalSchema = {
74
75
  name: "eval:kernel-tools",
75
76
  description: [
76
77
  "Kernel tools: functions a cell defines that in-process children can call (js and py; rb/jl answer tools_unavailable).",
@@ -90,12 +91,18 @@ const kernelToolsEntry: EvalSchemaResult = {
90
91
  },
91
92
  };
92
93
 
93
- const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map([
94
+ const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map<string, EvalSchemaResult>([
94
95
  [waitEntry.name, waitEntry],
95
96
  [helpersEntry.name, helpersEntry],
96
97
  [kernelToolsEntry.name, kernelToolsEntry],
98
+ [environmentsEntry.name, environmentsEntry],
99
+ [isolationEntry.name, isolationEntry],
97
100
  ]);
98
101
 
99
102
  export function virtualEvalSchema(name: string): EvalSchemaResult | undefined {
100
103
  return VIRTUAL_ENTRIES.get(name);
101
104
  }
105
+
106
+ export function virtualEvalSchemaNames(): readonly string[] {
107
+ return [...VIRTUAL_ENTRIES.keys()];
108
+ }
@@ -1,4 +1,9 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalImageContent } from "../tool/image-resize.ts";
3
+
4
+ /** A notification's message content: plain text, or text and the cells' images in one message. */
5
+ export type EvalNotificationContent = string | ({ readonly type: "text"; readonly text: string } | EvalImageContent)[];
6
+
2
7
  import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
3
8
 
4
9
  const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
@@ -11,7 +16,7 @@ export type EvalNotifyMode = "wake" | "next-turn" | "off";
11
16
  export interface EvalNotifierDeps {
12
17
  /** Deliver a model-visible notification without rendering synthetic user input. */
13
18
  readonly sendMessage: (
14
- message: { customType: string; content: string; display: boolean },
19
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
15
20
  options: { triggerTurn: boolean; deliverAs: "steer" | "followUp" },
16
21
  ) => void;
17
22
  readonly getContext: () => ExtensionContext | undefined;
@@ -43,10 +48,16 @@ export class EvalNotifier implements EvalDetachedCellNotifier {
43
48
  this.#deps.sendMessage(
44
49
  {
45
50
  customType: EVAL_NOTIFICATION_CUSTOM_TYPE,
46
- content: pending.map((cell) => cell.content).join("\n\n"),
51
+ content: notificationContent(pending),
47
52
  display: false,
48
53
  },
49
54
  { triggerTurn: true, deliverAs: mode === "wake" ? "steer" : "followUp" },
50
55
  );
51
56
  }
52
57
  }
58
+
59
+ function notificationContent(cells: readonly EvalDetachedCellNotification[]): EvalNotificationContent {
60
+ const text = cells.map((cell) => cell.content).join("\n\n");
61
+ const images = cells.flatMap((cell) => cell.images ?? []);
62
+ return images.length === 0 ? text : [{ type: "text", text }, ...images];
63
+ }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  resolveMaxDetachedCells,
11
11
  resolveRunBudgetSeconds,
12
12
  } from "./config/settings.ts";
13
- import { EvalNotifier } from "./extension/eval-notifier.ts";
13
+ import { type EvalNotificationContent, EvalNotifier } from "./extension/eval-notifier.ts";
14
14
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
15
15
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
16
16
  import { hostLine, modelIdFrom } from "./extension/host-facts.ts";
@@ -57,7 +57,7 @@ export interface CodemodeExtensionAPI {
57
57
  getActiveTools(): string[];
58
58
  getAllTools(): readonly EvalSchemaToolInfo[];
59
59
  sendMessage(
60
- message: { customType: string; content: string; display: boolean },
60
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
61
61
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
62
62
  ): void;
63
63
  /** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
@@ -0,0 +1,268 @@
1
+ import { currentCell, onReleaseOf, settleWithCell } from "./cell-run-context.js";
2
+
3
+ // What a cell owns, and how each kind ends when the cell is stopped. Every patch is a no-op outside a cell, refuses
4
+ // (or never starts) new work for a released cell, and registers what a live cell starts so release can end it.
5
+ const RESOURCE_CLOSERS = ["terminate", "destroy", "stop", "close", "end"];
6
+
7
+ // Some host bindings are read-only (Bun.fetch); those keep their own behaviour and are not owned by cells.
8
+ function replace(owner, name, replacement, patched) {
9
+ const original = owner[name];
10
+ try {
11
+ owner[name] = replacement;
12
+ } catch {
13
+ return;
14
+ }
15
+ if (owner[name] !== replacement) return;
16
+ patched.push(() => {
17
+ owner[name] = original;
18
+ });
19
+ }
20
+
21
+ function builtin(name) {
22
+ return process.getBuiltinModule?.(name);
23
+ }
24
+
25
+ function closeResource(resource) {
26
+ for (const method of RESOURCE_CLOSERS) {
27
+ if (typeof resource?.[method] !== "function") continue;
28
+ if (method === "stop") resource.stop(true);
29
+ else resource[method]();
30
+ return;
31
+ }
32
+ }
33
+
34
+ function ownResultsOf(owner, names, patched) {
35
+ for (const name of names) {
36
+ const original = owner?.[name];
37
+ if (typeof original !== "function") continue;
38
+ const own = function (...args) {
39
+ const cell = currentCell();
40
+ if (cell === undefined) return original.apply(this, args);
41
+ if (cell.released) throw cell.interruption;
42
+ const result = original.apply(this, args);
43
+ const register = (resource) => {
44
+ onReleaseOf(cell, () => closeResource(resource));
45
+ return resource;
46
+ };
47
+ return result instanceof Promise ? result.then(register) : register(result);
48
+ };
49
+ Object.assign(own, original);
50
+ replace(owner, name, own, patched);
51
+ }
52
+ }
53
+
54
+ // `globals` is the global scope already patched: a module binding that is the same owned function is left alone.
55
+ function patchTimers(scope, patched, globals = undefined) {
56
+ for (const [name, clearName] of [
57
+ ["setTimeout", "clearTimeout"],
58
+ ["setInterval", "clearInterval"],
59
+ ["setImmediate", "clearImmediate"],
60
+ ]) {
61
+ const original = scope?.[name];
62
+ const clear = scope?.[clearName];
63
+ if (typeof original !== "function" || typeof clear !== "function") continue;
64
+ if (globals !== undefined && original === globals[name]) continue;
65
+ const owned = function (callback, ...rest) {
66
+ const cell = currentCell();
67
+ if (cell === undefined || typeof callback !== "function") return original.call(this, callback, ...rest);
68
+ if (cell.released) {
69
+ const never = original.call(this, () => {}, ...rest);
70
+ clear.call(scope, never);
71
+ return never;
72
+ }
73
+ let forget = () => {};
74
+ const handle = original.call(
75
+ this,
76
+ function (...args) {
77
+ if (name !== "setInterval") forget();
78
+ return callback.apply(this, args);
79
+ },
80
+ ...rest,
81
+ );
82
+ forget = onReleaseOf(cell, () => clear.call(scope, handle));
83
+ return handle;
84
+ };
85
+ Object.assign(owned, original);
86
+ replace(scope, name, owned, patched);
87
+ }
88
+ }
89
+
90
+ function patchPromiseApi(owner, name, patched) {
91
+ const original = owner?.[name];
92
+ if (typeof original !== "function") return;
93
+ const bound = function (...args) {
94
+ const cell = currentCell();
95
+ if (cell === undefined) return original.apply(this, args);
96
+ if (cell.released) return Promise.reject(cell.interruption);
97
+ return settleWithCell(cell, original.apply(this, args));
98
+ };
99
+ Object.assign(bound, original);
100
+ replace(owner, name, bound, patched);
101
+ }
102
+
103
+ function patchTimerPromises(patched) {
104
+ const timers = builtin("node:timers/promises");
105
+ if (timers === undefined) return;
106
+ patchPromiseApi(timers, "setTimeout", patched);
107
+ patchPromiseApi(timers, "setImmediate", patched);
108
+ const original = timers.setInterval;
109
+ if (typeof original !== "function") return;
110
+ const owned = function (...args) {
111
+ const cell = currentCell();
112
+ if (cell?.released) throw cell.interruption;
113
+ const iterator = original.apply(this, args);
114
+ if (cell === undefined) return iterator;
115
+ onReleaseOf(cell, () => void iterator.return?.());
116
+ return iterator;
117
+ };
118
+ replace(timers, "setInterval", owned, patched);
119
+ }
120
+
121
+ function patchFetch(scope, patched, name = "fetch") {
122
+ const original = scope[name];
123
+ if (typeof original !== "function") return;
124
+ const owned = function (input, init) {
125
+ const cell = currentCell();
126
+ if (cell === undefined) return original.call(this, input, init);
127
+ if (cell.released) return Promise.reject(cell.interruption);
128
+ const controller = new AbortController();
129
+ const own = init?.signal ?? (input instanceof Request ? input.signal : undefined);
130
+ const signal = own === undefined ? controller.signal : AbortSignal.any([own, controller.signal]);
131
+ const forget = onReleaseOf(cell, () => controller.abort(cell.interruption));
132
+ return original.call(this, input, { ...init, signal }).finally(forget);
133
+ };
134
+ Object.assign(owned, original);
135
+ replace(scope, name, owned, patched);
136
+ }
137
+
138
+ // A released cell's code may still be resumed by a short I/O completion; refusing new I/O at that point makes such a
139
+ // loop throw the interruption on its next operation instead of running on in the kept worker.
140
+ function refuseWhenReleased(owner, names, patched) {
141
+ for (const name of names) {
142
+ const original = owner?.[name];
143
+ if (typeof original !== "function") continue;
144
+ const guarded = function (...args) {
145
+ const cell = currentCell();
146
+ if (cell?.released) throw cell.interruption;
147
+ const result = original.apply(this, args);
148
+ return cell === undefined ? result : settleWithCell(cell, result);
149
+ };
150
+ Object.assign(guarded, original);
151
+ replace(owner, name, guarded, patched);
152
+ }
153
+ }
154
+
155
+ // The callback form: a released cell's operation is never started, so its callback never runs, the same way a timer a
156
+ // released cell sets never fires.
157
+ function neverStartWhenReleased(owner, names, patched) {
158
+ for (const name of names) {
159
+ const original = owner?.[name];
160
+ if (typeof original !== "function") continue;
161
+ const guarded = function (...args) {
162
+ if (currentCell()?.released) return undefined;
163
+ return original.apply(this, args);
164
+ };
165
+ Object.assign(guarded, original);
166
+ replace(owner, name, guarded, patched);
167
+ }
168
+ }
169
+
170
+ const FS_OPERATIONS = ["readFile", "writeFile", "appendFile", "readdir", "stat", "lstat", "open", "access", "mkdir", "rm", "unlink", "rename", "copyFile"];
171
+
172
+ // For ES classes only (Worker, WebSocket, MessageChannel): `instanceof` keeps accepting instances the runtime creates
173
+ // internally from the original class. Node's function-style constructors are owned on activation instead.
174
+ function patchClass(scope, name, close, patched) {
175
+ const Original = scope?.[name];
176
+ if (typeof Original !== "function") return;
177
+ const Owned = class extends Original {
178
+ constructor(...args) {
179
+ const cell = currentCell();
180
+ if (cell?.released) throw cell.interruption;
181
+ super(...args);
182
+ if (cell !== undefined) onReleaseOf(cell, () => close(this));
183
+ }
184
+ static [Symbol.hasInstance](value) {
185
+ return value instanceof Original;
186
+ }
187
+ };
188
+ Object.defineProperty(Owned, "name", { value: Original.name });
189
+ replace(scope, name, Owned, patched);
190
+ }
191
+
192
+ const NODE_RESOURCE_FACTORIES = {
193
+ "node:net": ["createConnection", "connect", "createServer"],
194
+ "node:tls": ["connect", "createServer"],
195
+ "node:http": ["request", "get", "createServer"],
196
+ "node:https": ["request", "get", "createServer"],
197
+ "node:dgram": ["createSocket"],
198
+ "node:fs": ["createReadStream", "createWriteStream", "watch"],
199
+ "node:readline": ["createInterface"],
200
+ "node:readline/promises": ["createInterface"],
201
+ };
202
+
203
+ // Node's socket and server types are function-style constructors that Node's own subclasses call with `.call(this)`
204
+ // (http.Server calls net.Server), so they cannot be swapped for a class. A socket or server only does anything once it
205
+ // connects, listens or binds, so that call is where a cell takes ownership of it; http, https and tls inherit these.
206
+ const NODE_ACTIVATIONS = {
207
+ "node:net": { Socket: ["connect"], Server: ["listen"] },
208
+ "node:dgram": { Socket: ["bind"] },
209
+ };
210
+
211
+ function ownOnActivation(proto, names, patched) {
212
+ for (const name of names) {
213
+ const original = proto?.[name];
214
+ if (typeof original !== "function") continue;
215
+ const owned = new WeakSet();
216
+ const activate = function (...args) {
217
+ const cell = currentCell();
218
+ if (cell === undefined) return original.apply(this, args);
219
+ if (cell.released) throw cell.interruption;
220
+ if (!owned.has(this)) {
221
+ owned.add(this);
222
+ onReleaseOf(cell, () => closeResource(this));
223
+ }
224
+ return original.apply(this, args);
225
+ };
226
+ replace(proto, name, activate, patched);
227
+ }
228
+ }
229
+
230
+ export function installCellOwnership(scope = globalThis) {
231
+ const patched = [];
232
+ patchTimers(scope, patched);
233
+ patchTimers(builtin("node:timers"), patched, scope);
234
+ patchTimerPromises(patched);
235
+ patchFetch(scope, patched);
236
+ patchClass(scope, "WebSocket", (socket) => socket.close(), patched);
237
+ patchClass(scope, "Worker", (worker) => void worker.terminate(), patched);
238
+ refuseWhenReleased(builtin("node:fs/promises"), FS_OPERATIONS, patched);
239
+ neverStartWhenReleased(builtin("node:fs"), FS_OPERATIONS, patched);
240
+ patchClass(scope, "MessageChannel", (channel) => {
241
+ channel.port1.close();
242
+ channel.port2.close();
243
+ }, patched);
244
+ patchClass(scope, "BroadcastChannel", (channel) => channel.close(), patched);
245
+ const bun = scope.Bun;
246
+ if (bun !== undefined) {
247
+ patchPromiseApi(bun, "sleep", patched);
248
+ if (bun.fetch !== scope.fetch) patchFetch(bun, patched, "fetch");
249
+ refuseWhenReleased(bun, ["file", "write"], patched);
250
+ ownResultsOf(bun, ["connect", "listen", "serve", "udpSocket"], patched);
251
+ }
252
+ for (const [module, factories] of Object.entries(NODE_RESOURCE_FACTORIES)) ownResultsOf(builtin(module), factories, patched);
253
+ patchClass(builtin("node:worker_threads"), "Worker", (worker) => void worker.terminate(), patched);
254
+ for (const [module, types] of Object.entries(NODE_ACTIVATIONS)) {
255
+ for (const [name, methods] of Object.entries(types)) ownOnActivation(builtin(module)?.[name]?.prototype, methods, patched);
256
+ }
257
+ // Node hands `import("node:…")` its own ESM namespace; without this sync a cell importing the module gets the
258
+ // unowned originals.
259
+ syncBuiltinExports();
260
+ return () => {
261
+ for (const undo of patched.reverse()) undo();
262
+ syncBuiltinExports();
263
+ };
264
+ }
265
+
266
+ function syncBuiltinExports() {
267
+ builtin("node:module")?.syncBuiltinESMExports?.();
268
+ }
@@ -0,0 +1,89 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ // Every continuation a cell creates (awaits, timers, I/O callbacks) runs inside its cell record. Stopping a cell
4
+ // releases it: everything it owns is cleared, rejected or closed (`cell-ownership.js` decides what a cell owns), and
5
+ // anything its code tries to start afterwards is refused, while the kernel and its variables stay.
6
+ const cellRuns = new AsyncLocalStorage();
7
+ const releasedInterruptions = new WeakMap();
8
+ const MAX_CAUSE_DEPTH = 8;
9
+
10
+ export function runInCell(cell, run) {
11
+ return cellRuns.run(cell, run);
12
+ }
13
+
14
+ export function currentCell() {
15
+ return cellRuns.getStore();
16
+ }
17
+
18
+ export function releasedCellError() {
19
+ const cell = cellRuns.getStore();
20
+ return cell?.released === true ? cell.interruption : undefined;
21
+ }
22
+
23
+ export function assertCellLive() {
24
+ const error = releasedCellError();
25
+ if (error !== undefined) throw error;
26
+ }
27
+
28
+ export function onCellRelease(close) {
29
+ const cell = cellRuns.getStore();
30
+ return cell === undefined ? () => {} : onReleaseOf(cell, close);
31
+ }
32
+
33
+ export function onReleaseOf(cell, close) {
34
+ cell.onRelease ??= new Set();
35
+ cell.onRelease.add(close);
36
+ return () => cell.onRelease.delete(close);
37
+ }
38
+
39
+ /**
40
+ * Releases the cell: every closer runs even when an earlier one throws, and the failures are returned so the caller
41
+ * can report them; a resource that would not close is a fact the user should see, not something to swallow.
42
+ */
43
+ export function releaseCell(cell) {
44
+ cell.released = true;
45
+ releasedInterruptions.set(cell.interruption, cell);
46
+ const failures = [];
47
+ for (const close of cell.onRelease ?? []) {
48
+ try {
49
+ close();
50
+ } catch (error) {
51
+ failures.push(error);
52
+ }
53
+ }
54
+ cell.onRelease?.clear();
55
+ return failures;
56
+ }
57
+
58
+ /**
59
+ * The cell an unhandled rejection came from: a released cell whose interruption is the reason or sits in its `cause`
60
+ * chain, or the cell whose async context the rejection runs in (Node exposes it to the handler; Bun does not).
61
+ */
62
+ export function cellOfRejection(reason) {
63
+ let current = reason;
64
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth++) {
65
+ const cell = releasedInterruptions.get(current);
66
+ if (cell !== undefined) return cell;
67
+ current = current.cause;
68
+ }
69
+ return cellRuns.getStore();
70
+ }
71
+
72
+ // An operation already in flight when its cell is released rejects with the interruption at once, so the stopped
73
+ // cell's continuation never runs again; a non-promise result is returned as it is.
74
+ export function settleWithCell(cell, result) {
75
+ if (result === null || typeof result !== "object" || typeof result.then !== "function") return result;
76
+ return new Promise((resolve, reject) => {
77
+ const forget = onReleaseOf(cell, () => reject(cell.interruption));
78
+ result.then(
79
+ (value) => {
80
+ forget();
81
+ resolve(value);
82
+ },
83
+ (error) => {
84
+ forget();
85
+ reject(error);
86
+ },
87
+ );
88
+ });
89
+ }