@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  4. package/src/bridge/protocol.ts +8 -3
  5. package/src/bridge/reserved.ts +2 -0
  6. package/src/bridges/eval-environment-schemas.ts +48 -0
  7. package/src/bridges/eval-virtual-schemas.ts +11 -4
  8. package/src/bridges/reserved-dispatch.ts +13 -1
  9. package/src/config/executable-settings.json +5 -0
  10. package/src/config/project-trust.ts +51 -0
  11. package/src/config/settings-overrides.ts +2 -0
  12. package/src/environments/js-environments.ts +208 -0
  13. package/src/environments/js-installer.ts +268 -0
  14. package/src/environments/js-revision-files.ts +91 -0
  15. package/src/environments/no-links.ts +132 -0
  16. package/src/environments/packages-install.ts +98 -0
  17. package/src/environments/project-resolves.ts +54 -0
  18. package/src/environments/py-environment.ts +7 -3
  19. package/src/environments/py-installer.ts +5 -1
  20. package/src/environments/python-environments.ts +1 -1
  21. package/src/environments/revision-store.ts +20 -4
  22. package/src/extension/bridge-tool-call.ts +5 -1
  23. package/src/extension/eval-notifier.ts +13 -2
  24. package/src/extension/kernel-registration.ts +7 -2
  25. package/src/extension/runtime-factory.ts +51 -13
  26. package/src/extension/runtime-info.ts +6 -2
  27. package/src/extension/session-manager-contract.ts +3 -0
  28. package/src/extension/session-manager.ts +16 -2
  29. package/src/index.ts +3 -2
  30. package/src/interpreters/detect.ts +34 -1
  31. package/src/kernels/AGENTS.md +2 -0
  32. package/src/kernels/js/cell-dispatch.ts +44 -0
  33. package/src/kernels/js/cell-ownership.js +268 -0
  34. package/src/kernels/js/cell-run-context.js +89 -0
  35. package/src/kernels/js/context-manager.ts +46 -51
  36. package/src/kernels/js/control-frames.ts +24 -0
  37. package/src/kernels/js/host-entries.ts +62 -0
  38. package/src/kernels/js/kernel-contract.ts +16 -1
  39. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  40. package/src/kernels/js/kernel-memory.ts +79 -0
  41. package/src/kernels/js/kernel-tools-registry.js +1 -0
  42. package/src/kernels/js/local-module-loader.ts +17 -6
  43. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  44. package/src/kernels/js/process-drain-scan.js +28 -0
  45. package/src/kernels/js/process-entry.js +466 -0
  46. package/src/kernels/js/process-memory-host.ts +83 -0
  47. package/src/kernels/js/process-worker.ts +298 -0
  48. package/src/kernels/js/rejection-reports.js +61 -0
  49. package/src/kernels/js/run-queue.ts +7 -0
  50. package/src/kernels/js/worker-core.js +53 -8
  51. package/src/kernels/js/worker-cwd.js +5 -2
  52. package/src/kernels/js/worker-host.ts +9 -2
  53. package/src/kernels/js/worker-indirect-eval.js +37 -13
  54. package/src/kernels/js/worker-package-resolve.js +153 -0
  55. package/src/kernels/js/worker-require.d.ts +19 -0
  56. package/src/kernels/js/worker-require.js +52 -0
  57. package/src/kernels/js/worker-runtime.js +43 -9
  58. package/src/kernels/js/worker-shadow-guard.js +2 -6
  59. package/src/kernels/js/worker-shell-capture.js +5 -0
  60. package/src/kernels/js/worker-slot.ts +9 -0
  61. package/src/kernels/js/worker-startup.ts +49 -1
  62. package/src/kernels/js/worker-webview.js +15 -1
  63. package/src/kernels/py/host-cell.ts +8 -31
  64. package/src/kernels/py/prelude.py +33 -2
  65. package/src/kernels/py/process.ts +4 -0
  66. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  67. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  68. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  69. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  70. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  71. package/src/kernels/shared/host-cell.ts +57 -0
  72. package/src/kernels/shared/subprocess-process.ts +14 -0
  73. package/src/prompt/eval-prompt.ts +8 -1
  74. package/src/tool/AGENTS.md +1 -0
  75. package/src/tool/cell-handler.ts +12 -11
  76. package/src/tool/cell-reserved-dispatch.ts +1 -0
  77. package/src/tool/cell-runtime.ts +4 -2
  78. package/src/tool/detached-cell-contract.ts +3 -0
  79. package/src/tool/detached-cell-manager.ts +4 -8
  80. package/src/tool/detached-cell-notification.ts +11 -63
  81. package/src/tool/detached-cell-snapshot.ts +17 -0
  82. package/src/tool/detached-eval-result.ts +13 -7
  83. package/src/tool/detached-notification-queue.ts +1 -2
  84. package/src/tool/eval-request.ts +23 -1
  85. package/src/tool/eval-tool-options.ts +3 -0
  86. package/src/tool/eval-tool.ts +17 -4
  87. package/src/tool/interrupt-note.ts +4 -5
  88. package/src/tool/live-headline.ts +60 -0
  89. package/src/tool/load-cell.ts +18 -3
  90. package/src/tool/magic-cell-host.ts +60 -2
  91. package/src/tool/magic-cells.ts +12 -2
  92. package/src/tool/managed-cell.ts +0 -4
  93. package/src/tool/render.ts +74 -15
  94. package/src/tool/run-eval-cell.ts +46 -4
  95. package/src/tool/runtime-label.ts +2 -1
  96. package/src/tool/types.ts +23 -1
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionContext } from "@code-yeongyu/senpi";
3
+ import { readProcessFootprint } from "@code-yeongyu/senpi";
3
4
  import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
4
5
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
5
6
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
7
+ import { resolveJsIsolation, resolveKernelToolsEnabled } from "../config/feature-settings.ts";
6
8
  import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
7
9
  import { collectOrphanedChildren } from "../host-sdk.ts";
8
10
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
9
11
  import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
12
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
10
13
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
11
14
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
15
  import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
@@ -252,6 +255,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
252
255
  this.#options.localRoots ??
253
256
  (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
254
257
  if (language === "js") {
258
+ const memoryThresholds = resolveKernelMemoryThresholds(this.#options.settings.memory);
259
+ const processIsolation = resolveJsIsolation(this.#options.settings) === "process";
255
260
  const kernel = new JavaScriptKernel({
256
261
  sessionId: this.#options.sessionId,
257
262
  cwd: this.#options.cwd,
@@ -259,8 +264,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
259
264
  onMessage,
260
265
  hostToolNames: () => this.#options.listTools?.().map((tool) => tool.name) ?? [],
261
266
  foreignLanguageNames: () => this.#foreignKernelToolNames(),
262
- memory: resolveKernelMemoryThresholds(this.#options.settings.memory),
267
+ memory: memoryThresholds,
263
268
  collectOrphanedChildren,
269
+ ...(processIsolation ? { isolation: "process" as const } : {}),
270
+ ...(processIsolation && memoryThresholds !== undefined
271
+ ? { processMemory: { thresholds: memoryThresholds, readFootprint: readProcessFootprint } }
272
+ : {}),
273
+ ...(resolveKernelToolsEnabled(this.#options.settings) ? {} : { kernelToolsEnabled: false }),
264
274
  ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
265
275
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
266
276
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
@@ -276,10 +286,14 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
276
286
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
277
287
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
278
288
  };
289
+ // The Python prelude reads this when a cell defines an @tool; only a disabled setting adds it.
290
+ const sessionEnv: SessionEnvironment | undefined = resolveKernelToolsEnabled(this.#options.settings)
291
+ ? this.#options.sessionEnv
292
+ : { ...this.#options.sessionEnv, SENPI_CODEMODE_KERNEL_TOOLS: "0" };
279
293
  const shared = {
280
294
  sessionId: this.#options.sessionId,
281
295
  cwd: this.#options.cwd,
282
- ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
296
+ ...(sessionEnv ? { sessionEnv } : {}),
283
297
  connection,
284
298
  onMessage,
285
299
  ...lifecycle,
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. */
@@ -158,6 +158,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
158
158
  settings: runtime.settings,
159
159
  artifactsDir: runtime.artifactsDir,
160
160
  ...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
161
+ jsEnvironments: runtime.jsEnvironments,
161
162
  cellManager,
162
163
  handles,
163
164
  executionTracker: manager,
@@ -20,6 +20,8 @@ export interface InterpreterDetected {
20
20
 
21
21
  export interface InterpreterUnavailable {
22
22
  readonly ok: false;
23
+ /** Why a configured interpreter could not be used; unset when detection simply found none. */
24
+ readonly reason?: string;
23
25
  }
24
26
 
25
27
  export type InterpreterDetection = InterpreterDetected | InterpreterUnavailable;
@@ -43,6 +45,8 @@ export interface CreateInterpreterDetectorOptions {
43
45
 
44
46
  export interface InterpreterDetector {
45
47
  detect(language: CodemodeLanguage): Promise<InterpreterDetection>;
48
+ /** Probes exactly this executable (no PATH candidates, no splitting on spaces). */
49
+ detectExplicit(path: string): Promise<InterpreterDetection>;
46
50
  }
47
51
 
48
52
  export interface LanguageAvailability {
@@ -74,6 +78,9 @@ export function createInterpreterDetector(options: CreateInterpreterDetectorOpti
74
78
  cache.set(language, pending);
75
79
  return pending;
76
80
  },
81
+ detectExplicit(path) {
82
+ return probeExplicit(path, probe);
83
+ },
77
84
  };
78
85
  }
79
86
 
@@ -82,7 +89,7 @@ export async function getInterpreterAvailability(
82
89
  detector: InterpreterDetector,
83
90
  ): Promise<InterpreterAvailability> {
84
91
  return {
85
- py: await availabilityFor("py", settings.languages.py, detector),
92
+ py: await pythonAvailability(settings, detector),
86
93
  js: await availabilityFor("js", settings.languages.js, detector),
87
94
  rb: await availabilityFor("rb", settings.languages.rb, detector),
88
95
  jl: await availabilityFor("jl", settings.languages.jl, detector),
@@ -100,6 +107,32 @@ async function availabilityFor(
100
107
  };
101
108
  }
102
109
 
110
+ /** `languages.pyInterpreter`, when set, is the only interpreter Python uses; a path that does not answer makes Python unavailable. */
111
+ async function pythonAvailability(
112
+ settings: CodemodeSettings,
113
+ detector: InterpreterDetector,
114
+ ): Promise<LanguageAvailability> {
115
+ const configured = settings.languages.pyInterpreter;
116
+ if (configured === undefined || !settings.languages.py)
117
+ return await availabilityFor("py", settings.languages.py, detector);
118
+ return { enabled: true, detected: await detector.detectExplicit(configured) };
119
+ }
120
+
121
+ async function probeExplicit(path: string, probe: ExecFileProbe): Promise<InterpreterDetection> {
122
+ const reason = (detail: string): InterpreterUnavailable => ({
123
+ ok: false,
124
+ reason: `languages.pyInterpreter "${path}" ${detail}; Python is unavailable in this session`,
125
+ });
126
+ try {
127
+ const result = await probe(path, ["--version"], { timeoutMs: probeTimeoutMs });
128
+ const version = parseVersion(`${result.stdout}\n${result.stderr}`);
129
+ if (version === null) return reason("did not report a Python version");
130
+ return { ok: true, path, version, resolvedPath: path };
131
+ } catch (error) {
132
+ return reason(`could not run (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
133
+ }
134
+ }
135
+
103
136
  async function detectUncached(
104
137
  language: CodemodeLanguage,
105
138
  hostPlatform: NodeJS.Platform,
@@ -16,6 +16,8 @@ runner/prelude assets).
16
16
  | Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
17
17
  | Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
18
18
  | Session environment | `session-env.ts` (PI_* contract shared by all kernels; mirrors the core bash tool) |
19
+ | JS process isolation | `js/process-worker.ts` (host side), `js/process-entry.js` (child entry), `js/kernel-memory.ts`, `js/control-frames.ts` |
20
+ | Sandbox cells | `sandbox/sandbox-cell.ts` (host executor); `sandbox/vendor/pi-codemode/**` (vendored runtime, see its `VENDORED.md`) |
19
21
 
20
22
  ## CONVENTIONS
21
23
 
@@ -0,0 +1,44 @@
1
+ import type { HostToKernelMessage } from "../../bridge/protocol.ts";
2
+ import type { HostCellExecutor } from "../../tool/types.ts";
3
+ import { inputAtStart } from "../shared/cell-source-at-start.ts";
4
+ import { refusedEntry } from "./host-entries.ts";
5
+ import {
6
+ type JavaScriptKernelOptions,
7
+ type JavaScriptRunInput,
8
+ resolveKernelToolNameSource,
9
+ } from "./kernel-contract.ts";
10
+ import type { LocalModuleLoader } from "./local-module-loader.ts";
11
+
12
+ export type CellDispatch =
13
+ | { readonly kind: "host"; readonly host: HostCellExecutor }
14
+ | { readonly kind: "worker"; readonly frames: readonly HostToKernelMessage[] };
15
+
16
+ /**
17
+ * Decides at the cell's turn how it runs: as a host entry (an install, or a turn-time refusal such as an unreadable
18
+ * `%load` file) or in the worker, with the frames the worker needs (the current kernel-tool names, then the cell).
19
+ */
20
+ export function dispatchCell(
21
+ queued: JavaScriptRunInput,
22
+ loader: LocalModuleLoader,
23
+ options: Pick<JavaScriptKernelOptions, "hostToolNames" | "foreignLanguageNames">,
24
+ ): CellDispatch {
25
+ const input = inputAtStart(queued);
26
+ if ("refused" in input) return { kind: "host", host: refusedEntry(input.refused) };
27
+ if (input.host !== undefined) return { kind: "host", host: input.host };
28
+ return {
29
+ kind: "worker",
30
+ frames: [
31
+ {
32
+ type: "kernel-tools-names",
33
+ hostToolNames: resolveKernelToolNameSource(options.hostToolNames),
34
+ foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
35
+ },
36
+ {
37
+ type: "run",
38
+ cellId: input.cellId,
39
+ code: loader.prepareCell(input.code, input.kernelPreludes, input.sourceFile, input.packageRoot?.()),
40
+ timeoutMs: input.timeoutMs,
41
+ },
42
+ ],
43
+ };
44
+ }
@@ -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
+ }