@code-yeongyu/senpi-codemode 2026.10.10 → 2026.10.12

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 (110) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +113 -11
  3. package/package.json +7 -6
  4. package/src/bridge/memory-protocol.ts +8 -1
  5. package/src/bridge/protocol.ts +9 -4
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +52 -0
  8. package/src/bridges/eval-virtual-schemas.ts +11 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/editable-check.ts +1 -1
  14. package/src/environments/packages-install.ts +98 -0
  15. package/src/environments/py-environment.ts +7 -3
  16. package/src/environments/py-installer.ts +80 -44
  17. package/src/environments/python-environments.ts +1 -1
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/eval-cells-rpc.ts +62 -0
  20. package/src/extension/eval-notifier.ts +13 -2
  21. package/src/extension/kernel-registration.ts +7 -2
  22. package/src/extension/runtime-factory.ts +42 -13
  23. package/src/extension/runtime-info.ts +6 -2
  24. package/src/extension/session-cwd.ts +25 -9
  25. package/src/extension/session-manager-contract.ts +3 -0
  26. package/src/extension/session-manager.ts +20 -6
  27. package/src/index.ts +8 -3
  28. package/src/interpreters/detect.ts +34 -1
  29. package/src/kernels/AGENTS.md +2 -0
  30. package/src/kernels/jl/globals.jl +128 -0
  31. package/src/kernels/jl/prelude.jl +15 -2
  32. package/src/kernels/jl/runner.jl +35 -143
  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 +23 -24
  36. package/src/kernels/js/control-frames.ts +24 -0
  37. package/src/kernels/js/group-signal-notice.js +117 -0
  38. package/src/kernels/js/inline-worker.ts +2 -0
  39. package/src/kernels/js/kernel-contract.ts +16 -1
  40. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  41. package/src/kernels/js/kernel-memory.ts +79 -0
  42. package/src/kernels/js/kernel-tools-registry.js +1 -0
  43. package/src/kernels/js/local-module-loader.ts +8 -0
  44. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  45. package/src/kernels/js/process-drain-scan.js +28 -0
  46. package/src/kernels/js/process-entry.js +466 -0
  47. package/src/kernels/js/process-memory-host.ts +83 -0
  48. package/src/kernels/js/process-tree-host.ts +4 -8
  49. package/src/kernels/js/process-tree.d.ts +11 -0
  50. package/src/kernels/js/process-tree.js +55 -10
  51. package/src/kernels/js/process-worker.ts +298 -0
  52. package/src/kernels/js/rejection-reports.js +61 -0
  53. package/src/kernels/js/run-queue.ts +7 -0
  54. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  55. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  56. package/src/kernels/js/windows-tree-kill.js +100 -0
  57. package/src/kernels/js/worker-core.js +51 -8
  58. package/src/kernels/js/worker-cwd.js +46 -12
  59. package/src/kernels/js/worker-host.ts +9 -2
  60. package/src/kernels/js/worker-indirect-eval.js +37 -13
  61. package/src/kernels/js/worker-require.d.ts +19 -0
  62. package/src/kernels/js/worker-require.js +52 -0
  63. package/src/kernels/js/worker-runtime.js +66 -13
  64. package/src/kernels/js/worker-shadow-guard.js +2 -6
  65. package/src/kernels/js/worker-shell-capture.js +9 -0
  66. package/src/kernels/js/worker-slot.ts +8 -0
  67. package/src/kernels/js/worker-startup.ts +49 -1
  68. package/src/kernels/js/worker-webview.js +15 -1
  69. package/src/kernels/py/prelude.py +69 -27
  70. package/src/kernels/py/process.ts +4 -0
  71. package/src/kernels/py/transport.ts +4 -0
  72. package/src/kernels/rb/runner.rb +9 -19
  73. package/src/kernels/sandbox/sandbox-cell.ts +26 -1
  74. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  75. package/src/kernels/shared/bun-ffi.ts +24 -0
  76. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  77. package/src/kernels/shared/process-cpu.ts +7 -3
  78. package/src/kernels/shared/process-group-cpu.ts +6 -3
  79. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  80. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  81. package/src/kernels/shared/subprocess-process.ts +24 -2
  82. package/src/kernels/shared/subprocess-queue.ts +1 -0
  83. package/src/prompt/eval-prompt.ts +8 -1
  84. package/src/tool/AGENTS.md +1 -0
  85. package/src/tool/cell-handler.ts +3 -0
  86. package/src/tool/cell-reserved-dispatch.ts +1 -0
  87. package/src/tool/cell-runtime.ts +4 -2
  88. package/src/tool/detached-cell-contract.ts +3 -0
  89. package/src/tool/detached-cell-manager.ts +20 -11
  90. package/src/tool/detached-cell-notification.ts +11 -63
  91. package/src/tool/detached-cell-snapshot.ts +17 -0
  92. package/src/tool/detached-eval-result.ts +21 -10
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-tool.ts +4 -2
  95. package/src/tool/interrupt-note.ts +4 -5
  96. package/src/tool/live-headline.ts +60 -0
  97. package/src/tool/managed-cell.ts +0 -4
  98. package/src/tool/render-agent.ts +110 -0
  99. package/src/tool/render-blocks.ts +252 -0
  100. package/src/tool/render-call.ts +130 -0
  101. package/src/tool/render-cell.ts +165 -0
  102. package/src/tool/render-detail.ts +152 -0
  103. package/src/tool/render-live.ts +288 -0
  104. package/src/tool/render-result.ts +202 -0
  105. package/src/tool/render-status.ts +166 -0
  106. package/src/tool/render-tail.ts +139 -0
  107. package/src/tool/render.ts +3 -1069
  108. package/src/tool/run-eval-cell.ts +24 -14
  109. package/src/tool/runtime-label.ts +2 -1
  110. package/src/tool/types.ts +13 -2
@@ -16,6 +16,7 @@ include("prelude.jl")
16
16
  const senpi_connection = Dict{String, Any}()
17
17
  const senpi_write_lock = ReentrantLock()
18
18
  global senpi_current_cell = nothing
19
+ global senpi_memory_cell = nothing
19
20
 
20
21
  function senpi_escape(text::AbstractString)
21
22
  out = IOBuffer()
@@ -290,150 +291,31 @@ function senpi_set_connection(value)
290
291
  end
291
292
  end
292
293
 
293
- const senpi_memory = Ref{Any}(nothing)
294
+ const SENPI_GLOBALS_INCLUDED = Ref(false)
294
295
 
295
- const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_memory, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
296
-
297
- const SENPI_SIZER_SAMPLE = 1_000
298
- const SENPI_SIZER_NODE_BUDGET = 5_000
299
- const SENPI_SIZER_MAX_DEPTH = 64
300
- const SENPI_SIZER_POINTER = 8
301
- const SENPI_SIZER_OBJECT = 16
302
- const SENPI_SIZER_MIN_REPORTED = 1024 * 1024
303
-
304
- # Sizes a global with sampling and a node budget per global (leaves count too), so one deep global
305
- # never hides the ones measured after it and a huge container costs a bounded walk. Only concrete Base
306
- # containers are iterated; any other AbstractDict or AbstractSet is sized as an opaque struct, so no user
307
- # length or iterate method runs during a memory report.
308
- mutable struct SenpiSizer
309
- seen::IdDict{Any, Nothing}
310
- nodes::Int
311
- approximate::Bool
312
- end
313
- SenpiSizer() = SenpiSizer(IdDict{Any, Nothing}(), 0, false)
314
-
315
- senpi_over_budget(sizer::SenpiSizer) = sizer.nodes >= SENPI_SIZER_NODE_BUDGET
316
-
317
- function senpi_size(sizer::SenpiSizer, value, depth::Int)::Int
318
- sizer.nodes += 1
319
- value isa Type && return 0
320
- value isa Union{Number, Char, Bool, Symbol, Nothing} && return isbits(value) ? sizeof(value) : 0
321
- value isa String && return SENPI_SIZER_OBJECT + sizeof(value)
322
- if ismutable(value)
323
- haskey(sizer.seen, value) && return 0
324
- sizer.seen[value] = nothing
325
- end
326
- if depth >= SENPI_SIZER_MAX_DEPTH || senpi_over_budget(sizer)
327
- sizer.approximate = true
328
- return SENPI_SIZER_OBJECT
329
- end
330
- if value isa Array
331
- isbitstype(eltype(value)) && return SENPI_SIZER_OBJECT + sizeof(value)
332
- return SENPI_SIZER_OBJECT + length(value) * SENPI_SIZER_POINTER +
333
- senpi_sampled(sizer, length(value), depth) do index
334
- isassigned(value, index) ? value[index] : nothing
335
- end
336
- elseif value isa Union{Dict, IdDict}
337
- count = length(value)
338
- total = 0
339
- taken = 0
340
- for (key, item) in value
341
- senpi_over_budget(sizer) && break
342
- total += senpi_size(sizer, key, depth + 1) + senpi_size(sizer, item, depth + 1)
343
- taken += 1
344
- taken >= SENPI_SIZER_SAMPLE && break
345
- end
346
- taken < count && (sizer.approximate = true)
347
- return SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
348
- elseif value isa Union{Set, Tuple}
349
- count = length(value)
350
- total = 0
351
- taken = 0
352
- for item in value
353
- senpi_over_budget(sizer) && break
354
- total += senpi_size(sizer, item, depth + 1)
355
- taken += 1
356
- taken >= SENPI_SIZER_SAMPLE && break
357
- end
358
- taken < count && (sizer.approximate = true)
359
- return SENPI_SIZER_OBJECT + count * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
360
- end
361
- isbits(value) && return sizeof(value)
362
- fields = fieldcount(typeof(value))
363
- return SENPI_SIZER_OBJECT + fields * SENPI_SIZER_POINTER +
364
- senpi_sampled(sizer, fields, depth) do index
365
- isdefined(value, index) ? getfield(value, index) : nothing
366
- end
367
- end
368
-
369
- # Up to SENPI_SIZER_SAMPLE evenly spaced elements; once the budget runs out part-way, the elements measured
370
- # so far stand in for the rest.
371
- function senpi_sampled(at, sizer::SenpiSizer, count::Int, depth::Int)::Int
372
- count == 0 && return 0
373
- picks = min(count, SENPI_SIZER_SAMPLE)
374
- picks < count && (sizer.approximate = true)
375
- step = count / picks
376
- total = 0
377
- measured = 0
378
- for sample in 1:picks
379
- if senpi_over_budget(sizer)
380
- sizer.approximate = true
381
- break
296
+ function senpi_memory_globals(limit::Int)
297
+ if !SENPI_GLOBALS_INCLUDED[]
298
+ try
299
+ include(joinpath(@__DIR__, "globals.jl"))
300
+ SENPI_GLOBALS_INCLUDED[] = true
301
+ catch
302
+ return Dict{String, Any}[]
382
303
  end
383
- total += senpi_size(sizer, at(1 + floor(Int, (sample - 1) * step)), depth + 1)
384
- measured += 1
385
304
  end
386
- return measured == 0 ? 0 : round(Int, total / measured * count)
305
+ Base.invokelatest(() -> senpi_largest_globals(limit))
387
306
  end
388
307
 
389
- function senpi_largest_globals(limit::Int)
390
- try
391
- sizer = SenpiSizer()
392
- candidates = Tuple{String, Int, Bool}[]
393
- Base.invokelatest() do
394
- for name in names(Main, all = true)
395
- name in SENPI_MEMORY_INTERNALS && continue
396
- name in (:Main, :Base, :Core, :Ans, :ans) && continue
397
- lowered = lowercase(string(name))
398
- startswith(lowered, "senpi_") && continue
399
- startswith(string(name), "Senpi") && continue
400
- startswith(string(name), "#") && continue
401
- isdefined(Main, name) || continue
402
- value = getfield(Main, name)
403
- value isa Module && continue
404
- value isa IO && continue
405
- value isa Type && continue
406
- value isa Function && continue
407
- sizer.approximate = false
408
- sizer.nodes = 0
409
- bytes = try
410
- senpi_size(sizer, value, 0) + SENPI_SIZER_POINTER
411
- catch
412
- 0
413
- end
414
- bytes >= SENPI_SIZER_MIN_REPORTED && push!(candidates, (string(name), bytes, sizer.approximate))
415
- end
416
- end
417
- sort!(candidates, by = entry -> entry[2], rev = true)
418
- [approximate ? Dict{String, Any}("name" => name, "bytes" => bytes, "approximate" => true) : Dict{String, Any}("name" => name, "bytes" => bytes) for (name, bytes, approximate) in candidates[1:min(limit, end)]]
419
- catch
420
- Dict{String, Any}[]
421
- end
422
- end
423
-
424
- function senpi_memory_report()
425
- try
426
- senpi_memory[] === nothing && return nothing
427
- # The host reads the interpreter's current footprint and decides on the notice; the runner only
428
- # names its largest globals, with a bounded walk, so a cell never pays for an unbounded scan.
429
- named = senpi_largest_globals(5)
430
- # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
431
- report = Dict{String, Any}("liveBytes" => 0, "measure" => "footprint")
432
- isempty(named) || (report["globals"] = named)
433
- report
434
- catch
435
- nothing
308
+ function senpi_needs_handles(expression)
309
+ if expression isa Symbol
310
+ expression in (:handle, :completion, :eval, :include, :getfield, :getglobal, :deserialize) && return true
311
+ name = string(expression)
312
+ return startswith(name, "SenpiHandle") || startswith(name, "senpi_handle") ||
313
+ startswith(name, "senpi_wait") ||
314
+ (startswith(name, "SENPI_") && (occursin("HANDLE", name) || occursin("WAIT", name)))
436
315
  end
316
+ expression isa QuoteNode && return senpi_needs_handles(expression.value)
317
+ expression isa Expr && return any(senpi_needs_handles, expression.args)
318
+ false
437
319
  end
438
320
 
439
321
  function senpi_run_cell(message)
@@ -446,19 +328,23 @@ function senpi_run_cell(message)
446
328
  if parsed isa Expr && parsed.head === :error
447
329
  error(string(parsed.args[1]))
448
330
  end
331
+ # Install bindings before user functions/type annotations capture their world.
332
+ !SENPI_HANDLES_INCLUDED[] && senpi_needs_handles(parsed) && senpi_ensure_handles()
449
333
  value = Core.eval(Main, parsed)
450
334
  flush(stdout)
451
335
  flush(stderr)
452
336
  yield()
453
337
  frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
454
- value !== nothing && senpi_should_display_result(parsed) && (frame["valueRepr"] = senpi_json(value))
455
- memory = senpi_memory_report()
456
- memory !== nothing && (frame["memory"] = memory)
338
+ if value !== nothing && senpi_should_display_result(parsed)
339
+ # Core.eval does not advance this caller's world after a first lazy include.
340
+ frame["valueRepr"] = SENPI_HANDLES_INCLUDED[] ? Base.invokelatest(senpi_json, value) : senpi_json(value)
341
+ end
457
342
  senpi_emit(frame)
458
343
  catch error
459
344
  senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
460
345
  finally
461
346
  global senpi_current_cell = nothing
347
+ global senpi_memory_cell = cell_id
462
348
  end
463
349
  end
464
350
 
@@ -472,10 +358,16 @@ while !eof(SENPI_ORIGINAL_STDIN)
472
358
  if kind == "init"
473
359
  senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
474
360
  senpi_set_connection(get(message, "connection", nothing))
475
- senpi_memory[] = get(message, "memory", nothing)
476
- senpi_emit(Dict("type" => "ready"))
361
+ senpi_emit(Dict("type" => "ready", "memoryGlobals" => true))
477
362
  elseif kind == "run"
363
+ global senpi_memory_cell = nothing
478
364
  senpi_run_cell(message)
365
+ elseif kind == "memory-globals"
366
+ cell_id = get(message, "cellId", nothing)
367
+ if cell_id == senpi_memory_cell && senpi_current_cell === nothing
368
+ senpi_emit(Dict("type" => "memory-globals-result", "cellId" => cell_id, "globals" => senpi_memory_globals(5)))
369
+ global senpi_memory_cell = nothing
370
+ end
479
371
  elseif kind == "close"
480
372
  senpi_emit(Dict("type" => "closed"))
481
373
  break
@@ -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
+ }